Repository navigation
create skill quality metric #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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 objectThat fails That last part is what makes this more than a robustness nit. Everywhere else in the codebase 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 /
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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:
Suggest: render
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had designed the skill_quality to work with entire history. @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 |
||
|
|
||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Resource loading manifests as tool calls. 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 Either render
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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"}} | ||
| ] | ||
| }} | ||
| """ | ||
| 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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
The deterministic sort with Two small things: Discovery gap (this is the one that matters). nit: nit: a non-UTF-8 text file (UTF-16, latin-1 with high bytes) hits the |
||
| 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>" | ||
There was a problem hiding this comment.
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=Nonesilently reaches the prompt.Typed here as
Optional[str] = None, butSkillQualityMetric.__init__takesskillas a required positional. That mismatch doesn't surface, because_metric_decorator_factoryfunnels everything through**configand_create_metricends withkwargs.update(metric_config)— soNoneis passed by keyword and construction succeeds. Reproduced: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 stringNoneand 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);SkillQualityMetricdeclaresthreshold: float = 0.8. The tests assert0.8, but only because they construct the class directly — through the decorator, which is the documented path, a user always gets0.7. The class default is unreachable via public API.context_retention/consistency/topic_driftall 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 discoverflatten_skill_folderto produceskill, this is the one metric here that's genuinely unusable from the signature alone. AnExample:showingflatten_skill_folder(...)feedingskill=would close most of the gap.nit: stray blank line between the signature and
return— no other decorator in the file has it.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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