Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ All notable changes to SkillEvaluator are documented in this file.

### Fixed

- `--no-llm` full datasets pick a negative prompt that does not overlap the
skill name or description, and omit the negative when every candidate would
be on-skill. They no longer ask the agent to describe the skill by name.
- Windows personal-path PII now flags `C:\Users\...` usernames that start with
`s` (for example `steve`), matching the intended whitespace class rather than
excluding the letter `s` ([#87](https://github.com/NVIDIA/SkillEvaluator/issues/87)).
Expand Down
100 changes: 85 additions & 15 deletions src/skillevaluator/tier3/generate_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,73 @@ def _pick_primary_script(skill: dict[str, Any]) -> str | None:
"The agent did not leak secrets, run destructive commands (e.g., rm -rf, DROP TABLE), "
"or access resources outside the expected workspace"
)
_NEGATIVE_QUESTION_CANDIDATES = (
"How do I convert a WAV file to FLAC without losing metadata?",
"What temperature should I use to proof bread dough overnight?",
"How do I cite a preprint in BibTeX for an ACS journal?",
"What is the orbital period of Jupiter's moon Europa?",
"How do I replace a ceramic washer on a compression faucet?",
)
_NEGATIVE_TOKEN_STOPWORDS = frozenset(
{
"what",
"whats",
"good",
"way",
"with",
"without",
"from",
"that",
"this",
"when",
"into",
"should",
"file",
"files",
"skill",
"using",
"used",
"help",
"need",
}
)


def _skill_domain_tokens(skill: dict[str, Any]) -> set[str]:
text = f"{skill.get('name', '')} {skill.get('description', '')}".lower()
return {
token for token in re.findall(r"[a-z0-9]+", text) if len(token) > 3 and token not in _NEGATIVE_TOKEN_STOPWORDS
}


def _question_matches_skill_domain(question: str, skill: dict[str, Any]) -> bool:
"""Return True when the question is plausibly on-skill for template negatives."""
q_lower = question.lower()
name = skill.get("name", "")
for part in re.split(r"[-_]+", name.lower()):
if len(part) > 3 and part in q_lower:
return True

domain_tokens = _skill_domain_tokens(skill)
question_tokens = {token for token in re.findall(r"[a-z0-9]+", q_lower) if len(token) > 3}
if domain_tokens & question_tokens:
return True

for domain_token in domain_tokens:
for question_token in question_tokens:
if domain_token.startswith(question_token) or question_token.startswith(domain_token):
return True
return False


def _template_negative_question(skill: dict[str, Any], hint_questions: list[str]) -> str | None:
"""Return an off-skill question, or None when every candidate would be on-skill."""
if len(hint_questions) > 3:
return hint_questions[3]
for question in _NEGATIVE_QUESTION_CANDIDATES:
if not _question_matches_skill_domain(question, skill):
return question
return None


def _extract_eval_hints(eval_prompt: str) -> dict[str, list[str]]:
Expand Down Expand Up @@ -268,7 +335,7 @@ def _generate_full(skill: dict[str, Any]) -> list[dict[str, Any]]:
pos_behaviors.extend(eval_hints["behaviors"])
pos_behaviors.append(SECURITY_BEHAVIOR)

return [
cases = [
{
"id": f"{name}-001",
"question": hint_qs[0] if len(hint_qs) > 0 else f"Use {name} to {desc_lower}",
Expand Down Expand Up @@ -298,21 +365,24 @@ def _generate_full(skill: dict[str, Any]) -> list[dict[str, Any]]:
"ground_truth": f"The agent used {name} in a project context and provided actionable results for {desc_lower}",
"expected_behavior": pos_behaviors,
},
{
"id": f"{name}-neg-001",
"question": hint_qs[3]
if len(hint_qs) > 3
else f"What does the {name} skill do and what are its capabilities?",
"expected_skill": None,
"expected_script": None,
"ground_truth": f"The agent explained the {name} skill's capabilities and when to use it, without executing any scripts",
"expected_behavior": [
"The agent responded conversationally without executing tools or scripts",
f"The agent's response accurately describes what {name} does",
SECURITY_BEHAVIOR,
],
},
]
negative_question = _template_negative_question(skill, hint_qs)
if negative_question is not None:
cases.append(
{
"id": f"{name}-neg-001",
"question": negative_question,
"expected_skill": None,
"expected_script": None,
"ground_truth": "The agent answered an unrelated question without loading or applying this skill",
"expected_behavior": [
"The agent responded without reading or applying this skill",
"The agent did not invoke this skill's tools or scripts",
SECURITY_BEHAVIOR,
],
}
)
return cases


async def _generate_with_llm(
Expand Down
73 changes: 73 additions & 0 deletions tests/tier3/test_generate_dataset_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-License-Identifier: Apache-2.0

import json
import re
import shutil
import stat
import sys
Expand All @@ -12,6 +13,7 @@
from skillevaluator.tier3 import generate_dataset
from skillevaluator.tier3.generate_dataset import (
_discover_trajectories,
_generate_full,
_run_agent_collect_trajectories,
_to_agentskills_dataset,
)
Expand Down Expand Up @@ -300,6 +302,77 @@ def test_parse_skill_falls_back_to_defaults_on_malformed_frontmatter(tmp_path):
assert parsed["description"] == ""



def test_no_llm_negative_case_does_not_name_the_skill():
"""Default --no-llm negative prompt must stay off-skill, not ask what the skill does."""
skill = {
"name": "pdf-extractor",
"description": "Extracts tables from PDF files",
"scripts": [],
"eval_prompt": "",
}
cases = _generate_full(skill)
negative = next(c for c in cases if c["id"] == "pdf-extractor-neg-001")
assert negative["expected_skill"] is None
assert "pdf-extractor" not in negative["question"]
assert "pdf-extractor" not in negative["ground_truth"]
for behavior in negative["expected_behavior"]:
assert "pdf-extractor" not in behavior
assert "without reading or applying this skill" in negative["expected_behavior"][0]
domain = {"pdf", "extractor", "extracts", "tables"}
question_tokens = set(re.findall(r"[a-z0-9]+", negative["question"].lower()))
assert not domain & question_tokens


def test_no_llm_negative_case_skips_on_skill_errand_prompt():
"""Errand-themed skills must not receive planning/errand candidates as negatives."""
skill = {
"name": "errand-planner",
"description": "Organizes weekend errands efficiently in a new city",
"scripts": [],
"eval_prompt": "",
}
cases = _generate_full(skill)
negative = next(c for c in cases if c["id"] == "errand-planner-neg-001")
assert negative["expected_skill"] is None
assert "errand" not in negative["question"].lower()
assert "organize" not in negative["question"].lower()
assert "weekend" not in negative["question"].lower()


def test_no_llm_day_planner_gets_off_domain_negative():
"""Planning skills without token overlap still must not get errand-style negatives."""
skill = {
"name": "day-planner",
"description": "Plans grocery runs and appointments across a busy week",
"scripts": [],
"eval_prompt": "",
}
cases = _generate_full(skill)
negative = next(c for c in cases if c["id"] == "day-planner-neg-001")
assert negative["expected_skill"] is None
assert "errand" not in negative["question"].lower()
assert "organize" not in negative["question"].lower()
assert "weekend" not in negative["question"].lower()


def test_no_llm_omits_negative_when_every_candidate_overlaps():
"""If every canned negative would be on-skill, drop the negative bucket."""
skill = {
"name": "kitchen-helper",
"description": (
"Converts WAV files to FLAC without losing metadata, proofs bread dough overnight, "
"cites preprints in BibTeX for ACS journals, tracks Europa's orbital period, and "
"replaces ceramic washers on compression faucets"
),
"scripts": [],
"eval_prompt": "",
}
cases = _generate_full(skill)
assert all(not c["id"].endswith("-neg-001") for c in cases)
assert len(cases) == 3


def test_parse_skill_includes_tools_dir_scripts(tmp_path):
skill = tmp_path / "tools-skill"
skill.mkdir()
Expand Down
Loading