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
2 changes: 2 additions & 0 deletions fasteval/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def test_qa():
relevance,
rouge,
safety_check,
skill_quality,
speaker_diarization,
stack,
table_extraction,
Expand Down Expand Up @@ -214,6 +215,7 @@ def test_qa():
"context_retention",
"consistency",
"topic_drift",
"skill_quality",
"metric",
# Code-as-Judge
"judge",
Expand Down
2 changes: 2 additions & 0 deletions fasteval/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
metric,
relevance,
rouge,
skill_quality,
topic_drift,
toxicity,
traces,
Expand All @@ -41,6 +42,7 @@
"context_retention",
"consistency",
"topic_drift",
"skill_quality",
# Data Decorators
"csv",
"conversation",
Expand Down
25 changes: 25 additions & 0 deletions fasteval/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,31 @@ async def test_on_topic():
)


def skill_quality(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things on this decorator.

1. Blocking: skill=None silently reaches the prompt.

Typed here as Optional[str] = None, but SkillQualityMetric.__init__ takes skill as a required positional. That mismatch doesn't surface, because _metric_decorator_factory funnels everything through **config and _create_metric ends with kwargs.update(metric_config) — so None is passed by keyword and construction succeeds. Reproduced:

m = Evaluator()._create_metric(MetricConfig(
    metric_type="skill_quality", name="skill_quality",
    threshold=0.7, weight=1.0,
    config={"skill": None, "additional_skill_eval_criteria": None},
))
m.skill          # None
# prompt contains: <skill>\nNone\n</skill>

So @fe.skill_quality(threshold=0.9) — forgetting the single argument the metric exists for, which is easy since the signature says it's optional — scores the agent against the literal four-character string None and reports a number. Please raise at decoration time naming the argument, or make it a required parameter.

2. Threshold default disagrees with the metric class. Here it's default_threshold=0.7 (twice); SkillQualityMetric declares threshold: float = 0.8. The tests assert 0.8, but only because they construct the class directly — through the decorator, which is the documented path, a user always gets 0.7. The class default is unreachable via public API. context_retention / consistency / topic_drift all keep the two in sync.

3. No docstring. This is the only decorator in this file without one — every other has a description and an Example: block, and that's the pattern the docs are built from. Given a user also has to discover flatten_skill_folder to produce skill, this is the one metric here that's genuinely unusable from the signature alone. An Example: showing flatten_skill_folder(...) feeding skill= would close most of the gap.

nit: stray blank line between the signature and return — no other decorator in the file has it.

@Saavrm26 Saavrm26 Sep 7, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this completely slipped my mind. Have fixed this. Will push in the next commit along with the other fixes

threshold: float = 0.7,
weight: float = 1.0,
name: Optional[str] = None,
skill: Optional[str] = None,
additional_skill_eval_criteria: Optional[str] = None,
llm_client: Optional[LLMClient] = None,
model: Optional[str] = None,
**config: Any,
) -> Callable[[F], F]:

return _metric_decorator_factory(
"skill_quality", "skill_quality", default_threshold=0.7
)(
threshold=threshold,
weight=weight,
name=name,
llm_client=llm_client,
model=model,
skill=skill,
additional_skill_eval_criteria=additional_skill_eval_criteria,
**config,
)


# === Vision Metric Decorators ===


Expand Down
2 changes: 2 additions & 0 deletions fasteval/core/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from fasteval.metrics.conversation import (
ConsistencyMetric,
ContextRetentionMetric,
SkillQualityMetric,
TopicDriftMetric,
)
from fasteval.metrics.deterministic import ( # Tool Trajectory Metrics
Expand Down Expand Up @@ -82,6 +83,7 @@
"context_retention": ContextRetentionMetric,
"consistency": ConsistencyMetric,
"topic_drift": TopicDriftMetric,
"skill_quality": SkillQualityMetric,
}

# Register vision metrics if available
Expand Down
159 changes: 158 additions & 1 deletion fasteval/metrics/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

from typing import Any, Optional

from fasteval.metrics.llm import BaseLLMMetric
from pydantic import BaseModel, Field

from fasteval.metrics.llm import BaseLLMMetric, LLMEvalResponse
from fasteval.models.evaluation import EvalInput
from fasteval.utils.json_parsing import parse_json_response


class ContextRetentionMetric(BaseLLMMetric):
Expand Down Expand Up @@ -324,3 +327,157 @@ def get_evaluation_prompt(self, eval_input: EvalInput) -> str:
## OUTPUT FORMAT
Respond with JSON only:
{{"score": <0.0 to 1.0>, "reasoning": "<identify main topic, assess response alignment, note any drift or tangents>"}}"""


class SkillQualityCriteria(BaseModel):
"""A single criterion in a skill-quality evaluation response."""

name: str
applicable: bool
score: float | None = Field(default=None, ge=0.0, le=1.0)
reasoning: str = ""


class SkillQualityResponse(BaseModel):
"""Structured response returned by the skill-quality evaluator."""

criteria: list[SkillQualityCriteria]


class SkillQualityMetric(BaseLLMMetric):
def __init__(
self,
skill: str,
name: str = "skill_quality",
threshold: float = 0.8,
additional_skill_eval_criteria: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(name=name, threshold=threshold, **kwargs)
self.skill = skill
self.additional_skill_eval_criteria = additional_skill_eval_criteria

def _parse_response(self, response: str) -> LLMEvalResponse:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested schema defeats the shared fallback extractor, and the failure mode is the worst possible score.

parse_json_response -> extract_json_from_text has four strategies, and three of them are hard-coded to a flat {"score": ...} shape:

  • Try 3: re.search(r'\{[^{}]*"score"[^{}]*\}', ...) — [^{}]* cannot match nested objects, so on this schema it matches an inner criterion, not the envelope.
  • Try 4: regexes a bare score: value out of prose.

Reproduced:

extract_json_from_text('Sure!\n{"criteria": [{"name":"A","applicable":true,"score":1,"reasoning":"x"}]}')
# -> {'name': 'A', 'applicable': True, 'score': 1, 'reasoning': 'x'}    # the inner object

That fails SkillQualityResponse validation, retries 3x, and then BaseLLMMetric.evaluate (llm.py:143) returns score=0.0, passed=False.

That last part is what makes this more than a robustness nit. Everywhere else in the codebase 0.0 reads as "bad output." In this rubric 0 specifically means "the skill has a defect" — so a JSON parse failure is indistinguishable, in the report a user reads, from a genuinely broken skill. Someone will go rewrite a perfectly good SKILL.md chasing a parser bug.

Worth noting the prompt says "JSON only, no markdown fences" — and the fenced path is the only prose-tolerant strategy that works with a nested schema (I verified fenced output parses fine). So the instruction actively steers the model toward the shape that breaks.

Options, roughly in order of preference: use provider structured output / response_format if available; teach the extractor to brace-match for a nested envelope; at minimum make the parse-failure MetricResult distinguishable from a real 0 so it can't be misread as a skill defect.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was aware of Try 3 and Try 4 in parse_json_response call. I had considered the alternative of creating a normal json loading / fenced json loading in the metric itself and raising a value error if it didn't match the those(let's call this in-metric-parsing). But it would've created a code duplication and make it harder to maintain in long term. But directly editting the shared parse_json_response wouldn't be a good idea as it is being used by a lot of other metrics. Happy to accomodate in-metric-parsing or any other alternatives.

parsed = parse_json_response(response, SkillQualityResponse)
applicable_criteria = [
criterion for criterion in parsed.criteria if criterion.applicable
]
if not applicable_criteria:
raise ValueError(
"Skill quality response must contain an applicable criterion"
)
if any(criterion.score is None for criterion in applicable_criteria):
raise ValueError("Applicable skill quality criteria must include a score")

scores = [
criterion.score
for criterion in applicable_criteria
if criterion.score is not None
]
reasoning = "\n".join(
f"{criterion.name}: {criterion.reasoning}" for criterion in parsed.criteria
)
return LLMEvalResponse(
score=sum(scores) / len(scores),
reasoning=reasoning,
)

def get_evaluation_prompt(self, eval_input: EvalInput) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the judge sees an empty conversation in the most obvious usage.

This method reads only eval_input.history. It never touches eval_input.input or eval_input.actual_output. Every other metric in this file sends both, plus a "(No prior history)" fallback — see ContextRetentionMetric.get_evaluation_prompt at line 43 and TopicDriftMetric at line 247.

Reproduced on this branch:

m = SkillQualityMetric(skill="S")
p = m.get_evaluation_prompt(EvalInput(input="ZZQUERYZZ", actual_output="ZZOUTPUTZZ"))
"ZZQUERYZZ" in p    # False
"ZZOUTPUTZZ" in p   # False
# prompt contains: <conversation_history>\n\n</conversation_history>

Two ways a user hits this:

  1. Plain fe.score(...). Someone writes @fe.skill_quality(skill=...) over a normal single-turn test — which the signature gives no reason not to do — and the judge gets an empty <conversation_history> block. It doesn't error. It doesn't warn. It returns a confident score derived from nothing.

  2. Even with @fe.conversation. decorators.py:1848 passes history.copy() before appending the current turn. So turn 1 sees [], and every turn's judgment excludes the exact turn being judged. The final exchange in the conversation is never evaluated by anything.

Suggest: render input/actual_output as the current turn, and use the "(No conversation history provided)" fallback the siblings use. If this metric is genuinely @fe.conversation-only, that needs to be stated in a docstring — but the turn-1 gap still needs fixing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had designed the skill_quality to work with entire history.
Something like

@fe.skill_quality(skill=flatten_skill_folder(...))
def eval_skill():
  history = agent.invoke(...)
  fe.score(history=history)

Tbh I wasn't aware of the @fe.conversation decorator (was too focused with the prompt and things to evaluate 😅).

That said adapting the signature to work with @fe.conversation would actually prove beneficial for things like elicitation, etc evaluations as well. I'll incorporate that


history_str = ""
if eval_input.history:
history_str = "\n".join(
f"**{turn.get('role', 'unknown').title()}**: {turn.get('content', '')}"
for turn in eval_input.history
)
history_str = f"\n{history_str}\n"
expected_output = eval_input.expected_output or "N/A"
skill = self.skill
specialized_criteria = self.additional_skill_eval_criteria or "N/A"

return f"""
<conversation_history>
{history_str}
</conversation_history>

<expected_output>
{expected_output}
</expected_output>

<skill>
{skill}
</skill>

<specialized_criteria>
{specialized_criteria}
</specialized_criteria>

You are a judge evaluating whether a SKILL (packaged metadata/instructions/resources) is
well designed — not whether the agent using it is competent. Your job is to find defects
in the SKILL for iterative improvement, not to grade the agent.

## Root-cause rule (applies to every criterion below)
When the agent's behavior falls short, decide WHY before scoring:
- Traces back to something missing/ambiguous/wrong in the skill → skill defect → score 0.
- Skill was clear and adequate, agent just ignored/misread/hallucinated anyway → agent
failure, not the skill's fault → score 1 (note it in reasoning so it isn't mistaken for
a needed skill fix).
- If you cannot point to specific text in the skill that explains the failure, default to
1 and say so — do not assume a skill defect without textual evidence.

## Skill loading model (judge accordingly)
- Metadata (name + description): visible to the agent at ALL times, before
any decision to use the skill.
- Instructions (full SKILL.md body): loaded only once the skill is activated.
- Resources (files under scripts/, references/, assets/): loaded on demand, only if the
task needs that depth.
Judge each criterion against the layer the agent actually had access to at that point.

## Input format (data supplied above, inside tags)
- <conversation_history>: conversation between user and agent.
- <expected_output> (optional): reference conversation showing correct invocation and/or
correct final output.
- <skill>: skill under test, parseable into metadata / instructions / resources.
- <specialized_criteria> (optional): additional use-case-specific criteria, one per entry.

Evaluate each criterion independently. Do NOT sum or average scores yourself — return a
structured verdict per criterion; aggregation happens outside this prompt.

## Criteria

1. SKILL_MATCHING (always applicable) — judges the metadata layer only
Using ONLY name + description, was the skill discoverable for this user intent, and (if
relevant) activated without unnecessary delay?
- 1: description clearly matched intent and skill was activated promptly, OR skill was
irrelevant and correctly not activated, OR description was clear/adequate but the
agent still missed or delayed activation (agent-side miss, not a metadata defect).
- 0: description was vague, missing relevant keywords, misleading, or overly broad, and
THAT is what caused the wrong decision (missed, late, or false-positive activation).

2. SKILL_FOLLOWING (applicable only if the skill was activated) — judges instructions +
resources

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SKILL_FOLLOWING asks the judge to grade "resource-loading discipline (loading a resource only when the task needed that depth; not skipping one it was told to load; not hallucinating detail a resource would have supplied)."

Resource loading manifests as tool calls. EvalInput carries tool_calls, but nothing here renders it into the prompt. So the judge is asked to grade behavior it structurally cannot observe.

And by this prompt's own root-cause rule — "If you cannot point to specific text in the skill that explains the failure, default to 1" — the judge defaults to 1 whenever it can't see evidence. Which is always. Criterion 2 becomes near-constant pass noise that dilutes the average in _parse_response.

Either render eval_input.tool_calls into the prompt, or drop the resource-discipline clause so the criterion only claims what it can actually assess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource loading manifests as tool calls. EvalInput carries tool_calls, but nothing here renders it into the prompt. So the judge is asked to grade behavior it structurally cannot observe.
The conversation history will have the tool role, which contains the skill resources.

Additionally, the the actual steps of (Human, AI, Tool) would be needed for the judge. Passing tool_calls would only have tools

Did the agent's actions align with the skill's instructions, including resource-loading
discipline (loading a resource only when the task needed that depth; not skipping one
it was told to load; not hallucinating detail a resource would have supplied)?
- 1: agent followed instructions and handled resources correctly, OR agent deviated but
the instructions/resources were clear and adequate for the situation (agent's fault,
not the skill's).
- 0: instructions were silent, ambiguous, contradictory, or a needed resource was
missing for a situation that actually came up, and that gap caused the deviation.
- Not applicable if skill was never activated — mark applicable:false, do not score 0.

3. EXPECTED_OUTPUT_MATCH (applicable only if expected_output is provided)
Does the actual conversation's outcome match the expected one in substance (key
facts/decisions/deliverable), not verbatim?
- 1: matches on key elements. 0: missing/contradicts key elements.

4+. SPECIALIZED CRITERIA — evaluate exactly as described in the input, one per entry.

## Output format — JSON only, no markdown fences:
{{
"criteria": [
{{"name": "SKILL_MATCHING", "applicable": true, "score": 1, "reasoning": "..."}},
{{"name": "SKILL_FOLLOWING", "applicable": false, "score": null, "reasoning": "skill never activated"}}
]
}}
"""
2 changes: 2 additions & 0 deletions fasteval/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fasteval.utils.async_helpers import run_async
from fasteval.utils.formatting import format_evaluation_report
from fasteval.utils.json_parsing import extract_json_from_text, parse_json_response
from fasteval.utils.skill_parsing import flatten_skill_folder
from fasteval.utils.terminal_ui import (
HumanScore,
is_interactive,
Expand All @@ -24,4 +25,5 @@
"prompt_human_review",
"render_human_review",
"render_conversation_history",
"flatten_skill_folder",
]
85 changes: 85 additions & 0 deletions fasteval/utils/skill_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Utilities for reading Agent Skills directories."""

from pathlib import Path
from typing import List
from xml.sax.saxutils import escape, quoteattr

DEFAULT_RESOURCE_DIRECTORIES = ("references", "scripts", "assets")


def flatten_skill_folder(
skill_path: str | Path,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is the strong part of the PR and I want to say so specifically, because it's the kind of thing that usually gets skipped:

  • symlinked SKILL.md rejected, not followed
  • symlinked default resource directories skipped rather than walked
  • absolute paths and .. rejected in additional_resource_paths
  • resolve().is_relative_to(root) as a real escape check on top of the textual one
  • rglob filtered on is_symlink()
  • and every one of those has a test, including the "secret" fixture that asserts outside content never appears in the output

The deterministic sort with SKILL.md pinned first is also a nice touch — stable prompt prefix is good for cache hit rates on repeated runs.

Two small things:

Discovery gap (this is the one that matters). SkillQualityMetric takes skill: str, and this function is what produces that string — but nothing connects them. It isn't re-exported at the top level (consistent with the rest of utils, so fine), isn't named in the decorator (which has no docstring), and isn't in the docs. Realistic first run: user passes a directory path, or raw SKILL.md text, and gets a plausible score against the wrong thing. Consider having the decorator accept a skill path and flatten internally — the metric already wants a directory.

nit: List[str] | List[Path] should probably be Sequence[str | Path]. As typed, ["notes.md", Path("examples")] is a type error even though it works fine at runtime, and the tests only pass homogeneous lists so it never shows up.

nit: a non-UTF-8 text file (UTF-16, latin-1 with high bytes) hits the UnicodeDecodeError branch and becomes a contentless <resource ... /> with no signal. Correct for logo.png, but a mis-encoded references/guide.md would silently vanish from the judge's view while still looking present in the XML.

additional_resource_paths: List[str] | List[Path] | None = None,
) -> str:
"""Return an XML representation of an Agent Skills directory."""
root = Path(skill_path)
if not root.is_dir():
raise ValueError(f"Skill path must be a directory: {root}")
skill_md = root / "SKILL.md"
if not skill_md.is_file() or skill_md.is_symlink():
raise ValueError(f"Skill directory must contain SKILL.md: {root}")

resource_paths = [skill_md]
resource_paths.extend(
path
for directory in DEFAULT_RESOURCE_DIRECTORIES
if (path := root / directory).is_dir() and not path.is_symlink()
)

for additional_path in additional_resource_paths or []:
relative_path = Path(additional_path)
if relative_path.is_absolute() or ".." in relative_path.parts:
raise ValueError(
f"Additional resource path must be relative to the skill directory: "
f"{additional_path}"
)

resource_path = root / relative_path
if not resource_path.exists():
raise ValueError(
f"Additional resource path does not exist: {additional_path}"
)
if resource_path.is_symlink():
raise ValueError(
f"Additional resource path must not be a symbolic link: "
f"{additional_path}"
)
if not resource_path.resolve().is_relative_to(root.resolve()):
raise ValueError(
f"Additional resource path must resolve within the skill directory: "
f"{additional_path}"
)
resource_paths.append(resource_path)

files = set()
for resource_path in resource_paths:
if resource_path.is_file():
files.add(resource_path)
elif resource_path.is_dir():
files.update(
path
for path in resource_path.rglob("*")
if path.is_file() and not path.is_symlink()
)

sorted_files = sorted(
files,
key=lambda path: (
path.relative_to(root).as_posix() != "SKILL.md",
path.relative_to(root).as_posix(),
),
)

elements = []
for path in sorted_files:
relative_posix = path.relative_to(root).as_posix()
path_attribute = quoteattr(relative_posix)
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
elements.append(f" <resource path={path_attribute} />")
continue

elements.append(f" <file path={path_attribute}>\n{escape(content)}\n </file>")

return "<skill>\n" + "\n".join(elements) + "\n</skill>"
Loading
Loading