feat: add skill-level evaluators for skill-equipped agents - #330
feat: add skill-level evaluators for skill-equipped agents#330sangminwoo wants to merge 25 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds first-class “skill” evaluation support to strands-evals by (1) extracting offered/selected skills from trajectories across multiple harness formats, and (2) introducing new judge + deterministic evaluators to score skill selection and adherence. It also fixes system-prompt loss in session mappers (needed because available skills are often injected into the system prompt) and adjusts report aggregation to avoid deflating overall scores with not_applicable rows.
Changes:
- Add skill parsing helpers (
parse_available_skills,extract_selected_skills) that work on bothSessionobjects and raw message lists, plus trajectory serialization with truncation. - Add skill evaluators:
SkillSelectionAccuracyEvaluator,SkillInstructionFollowingEvaluator, and deterministicSkillInvoked, with versioned prompt templates and unit tests. - Fix mapper/system plumbing: backfill
AgentInvocationSpan.system_promptin Strands + OpenInference mappers; update overall score aggregation to exclude fully-not_applicablerows; declarepyyamldependency.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/strands_evals/types/test_evaluation_report.py | Adds regression tests for excluding not_applicable rows from overall score aggregation. |
| tests/strands_evals/mappers/test_strands_in_memory_mapper.py | Verifies Strands in-memory mapper now captures/backfills system prompt for skill catalog parsing. |
| tests/strands_evals/mappers/test_openinference_session_mapper.py | Verifies OpenInference mapper backfills system prompt onto agent spans regardless of span order. |
| tests/strands_evals/extractors/test_skills.py | New unit tests covering skill catalog parsing + invocation extraction across harness formats. |
| tests/strands_evals/extractors/skill_fixtures.py | Golden fixtures for cross-harness raw message shapes used by skill extractor tests. |
| tests/strands_evals/evaluators/test_skill_selection_accuracy_evaluator.py | New tests for skill selection judge prompt construction, looping semantics, and label/score mapping. |
| tests/strands_evals/evaluators/test_skill_instruction_following_evaluator.py | New tests for step-status grounding, ordinal mapping, N/A aggregation behavior, and async path. |
| tests/strands_evals/evaluators/deterministic/test_skill_invoked.py | New tests for deterministic skill presence evaluator. |
| src/strands_evals/types/evaluation_report.py | Adds calculate_overall_score to skip fully-not_applicable rows when averaging. |
| src/strands_evals/mappers/strands_in_memory_session_mapper.py | Extracts system prompt from GenAI conventions and backfills it into agent spans. |
| src/strands_evals/mappers/openinference_session_mapper.py | Ensures system prompt is parsed/cached from span events and backfilled into agent spans. |
| src/strands_evals/extractors/skills.py | New skill extraction/parsing module (available catalog + invoked skills + serialization). |
| src/strands_evals/extractors/init.py | Exposes skill extractors and serialization helpers from the extractors package. |
| src/strands_evals/experiment.py | Uses EvaluationReport.calculate_overall_score during async experiment reporting. |
| src/strands_evals/evaluators/skill_selection_accuracy_evaluator.py | New judge evaluator for per-skill selection appropriateness (and abstention correctness). |
| src/strands_evals/evaluators/skill_instruction_following_evaluator.py | New judge evaluator for per-skill step adherence using a five-point ordinal rubric. |
| src/strands_evals/evaluators/prompt_templates/skill_selection_accuracy/skill_selection_accuracy_v0.py | New prompt template for skill selection accuracy judge. |
| src/strands_evals/evaluators/prompt_templates/skill_selection_accuracy/init.py | Version registry for skill selection accuracy prompt templates. |
| src/strands_evals/evaluators/prompt_templates/skill_instruction_following/skill_instruction_following_v0.py | New prompt template for skill instruction-following judge. |
| src/strands_evals/evaluators/prompt_templates/skill_instruction_following/init.py | Version registry for skill instruction-following prompt templates. |
| src/strands_evals/evaluators/deterministic/skill_invoked.py | New deterministic evaluator that checks whether a named skill was invoked. |
| src/strands_evals/evaluators/deterministic/init.py | Exports SkillInvoked from deterministic evaluators. |
| src/strands_evals/evaluators/init.py | Exports new skill evaluators at the package top level. |
| SKILL.md | Documents newly added skill-level evaluators and their scoring behavior. |
| README.md | Adds user-facing docs and example usage for the new skill evaluators. |
| pyproject.toml | Declares direct pyyaml dependency (used to parse SKILL.md frontmatter). |
| AGENTS.md | Documents strands.Skill usage as a real SDK import used by skill extractors. |
Comments suppressed due to low confidence (3)
src/strands_evals/evaluators/skill_instruction_following_evaluator.py:148
- When
actual_trajectoryisNone,extract_selected_skills(None)returns[], so this path currently reports "no skill invoked" and (via the aggregator) a passing result. That treats missing data as a correct no-skill run. Add an explicitNonecheck and return anot_applicablerow withtest_pass=False(consistent withSkillSelectionAccuracyEvaluator).
def evaluate(self, evaluation_case: EvaluationData[InputT, OutputT]) -> list[EvaluationOutput]:
invoked = extract_selected_skills(evaluation_case.actual_trajectory)
if not invoked:
return [self._not_applicable_row("no skill invoked")]
src/strands_evals/evaluators/skill_instruction_following_evaluator.py:164
- Same as the sync path:
actual_trajectory=Nonecurrently falls through to "no skill invoked" (passing) becauseextract_selected_skills(None)returns[]. Add an explicitNonecheck and return a failingnot_applicablerow.
async def evaluate_async(self, evaluation_case: EvaluationData[InputT, OutputT]) -> list[EvaluationOutput]:
invoked = extract_selected_skills(evaluation_case.actual_trajectory)
if not invoked:
return [self._not_applicable_row("no skill invoked")]
src/strands_evals/evaluators/skill_selection_accuracy_evaluator.py:112
- Same issue in
evaluate_async: when the available-skill catalog is missing, the judge call still happens with "(none listed)", producing a score that isn’t grounded in the offered-skill set. Consider short-circuiting to anot_applicablerow whenparse_available_skills(...)returns empty.
async def evaluate_async(self, evaluation_case: EvaluationData[InputT, OutputT]) -> list[EvaluationOutput]:
if evaluation_case.actual_trajectory is None:
return [self._missing_trajectory_row()]
invoked = extract_selected_skills(evaluation_case.actual_trajectory)
evaluator_agent = Agent(model=self.model, system_prompt=self.system_prompt, callback_handler=None)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A skill is an instruction file (usually SKILL.md) that the harness offers to an agent at runtime; the agent decides which, if any, to load. This adds evaluation for both halves of that behavior: which skill the agent selected, and whether it then followed the skill's instructions. Parsing helpers (extractors/skills.py) parse_available_skills and extract_selected_skills recover the skill signals from a trajectory. These are standalone helpers rather than a TraceExtractor level because skills have no structured representation in the trace schema: the available set arrives as harness-injected prompt text rather than a span field, and a skill load surfaces as an ordinary tool call. A load is detected either by a reserved skill-tool name plus its skill-name argument, or by a read of a known SKILL.md path. Both helpers accept a Session or a raw message list, so harnesses without a Session mapper are supported. Evaluators SkillSelectionAccuracyEvaluator judges whether each invoked skill was an appropriate pick, one output per invoked skill, mirroring ToolSelectionAccuracyEvaluator. When no skill was invoked it judges the abstention itself. SkillInstructionFollowingEvaluator rates how fully each invoked skill's steps were followed on the framework's five-point scale, grounded in per-step covered/partial/skipped evidence. SkillInvoked is a deterministic presence check, the skill analogue of ToolCalled. Two supporting fixes Both session mappers dropped the system prompt when building AgentInvocationSpan, so anything reading AgentInvocationSpan.system_prompt saw None. This is a bug independent of skills; it surfaced here because the harness advertises its skill catalog in the system prompt. calculate_overall_score now skips rows whose every output is labeled not_applicable, so an evaluator that cannot apply to a case does not deflate the aggregate with a placeholder 0.0. Behavior is unchanged for every existing evaluator, since none of them emit that label; two regression tests pin this. Declares pyyaml, used to read SKILL.md frontmatter.
e5cca80 to
6786c56
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
src/strands_evals/evaluators/skill_instruction_following_evaluator.py:184
SkillInstructionFollowingEvaluator.evaluate_async()also creates a newAgentper invoked skill. Reusing a singleAgentinstance per case avoids repeated setup and reduces latency/cost when multiple skills are invoked.
prompt = self._build_prompt(skill, evaluation_case)
evaluator_agent = Agent(model=self.model, system_prompt=self.system_prompt, callback_handler=None)
result = await evaluator_agent.invoke_async(prompt, structured_output_model=SkillFollowingRating)
rating = cast(SkillFollowingRating, result.structured_output)
results.append(self._rating_to_output(skill, rating))
src/strands_evals/extractors/skills.py:678
extract_selected_skillscurrently skips reserved skill-tool invocations when the tool call has no matchingtoolResult. That can undercount skill loads in trajectories where tool responses are not captured (or logs are truncated), even though the tool call itself indicates the agent attempted to load the skill. Consider counting keyed skill-tool calls as an invocation withbody=Nonewhen the result is missing, while still excluding explicit failures and keeping the stricter behavior for unkeyed calls.
if skill_name is not None:
if matched_result is None or matched_result[0]:
continue
body = matched_result[1]
if tool_name == "Skill" and body is None:
src/strands_evals/evaluators/skill_instruction_following_evaluator.py:166
SkillInstructionFollowingEvaluator.evaluate()constructs a newAgentinside the per-skill loop. Since the agent is stateless across calls here (single prompt, structured output), this adds unnecessary overhead for multi-skill runs. Instantiate oneAgentonce per evaluation and reuse it across skills.
This issue also appears on line 180 of the same file.
prompt = self._build_prompt(skill, evaluation_case)
evaluator_agent = Agent(model=self.model, system_prompt=self.system_prompt, callback_handler=None)
result = evaluator_agent(prompt, structured_output_model=SkillFollowingRating)
rating = cast(SkillFollowingRating, result.structured_output)
results.append(self._rating_to_output(skill, rating))
| if evaluation_case.actual_trajectory is None: | ||
| return [self._missing_trajectory_row()] | ||
| invoked = extract_selected_skills(evaluation_case.actual_trajectory) | ||
| evaluator_agent = Agent(model=self.model, system_prompt=self.system_prompt, callback_handler=None) |
There was a problem hiding this comment.
I noticed that this is inconsistent with the way the flow works in other evaluators like the tool selection accuracy evaluator.
In the other evaluators, e.g., https://github.com/strands-agents/evals/blob/main/src/strands_evals/evaluators/tool_selection_accuracy_evaluator.py#L50-L57, we instantiate a fresh copy of the agent inside the loop, i.e., for every tool we instantiate a fresh judge agent and invoke that fresh agent.
Here, we instantiate the judge agent (on L98) and then reuse that agent for each and every skill. I think this means we'll accumulate turns from different skill evaluations in the judge conversation, right? If that's the right reading of the current implementation, we should change it to instantiate the judge agent inside the loop.
There was a problem hiding this comment.
Thanks that's a good catch. Reusing the judge accumulated prior turns and resent the trajectory on top of that history. I fixed this by creating a fresh judge per skill while building the shared case context once. The tests now assert one judge instance and one prompt per skill.
|
|
||
|
|
||
| def _body_from_result(result: Any) -> str | None: | ||
| """Return actual skill instructions, excluding errors and load acknowledgements.""" |
There was a problem hiding this comment.
If the intention is to exclude errors, I think there's a gap here, specifically for Strands’ AgentSkills plugin.
AgentSkills registers configured SKILL.md files and exposes a skills(skill_name=...) tool that the model uses to load one. The argument is a lookup key, not a skill definition. For example, if pdf-processing is registered but the model requests pdf-procesing (one "s", i.e., the model made a typo), Then Strands v1.42.0 returns the ordinary string "Skill 'pdf-procesing' not found...".
The tool decorator marks ordinary string returns as status="success", so _result_failed() returns false and this function treats the error message as the loaded skill body. SkillInvoked then reports that the skill was loaded, and SkillInstructionFollowingEvaluator may judge the error text as instructions. Could we add a regression fixture using this exact SDK response and either recognize it as a failed load rather than a skill body?
There was a problem hiding this comment.
Good point. AgentSkills error strings are no longer treated as skill bodies, and named refusals are recorded as failed loads. Skill 'x' activated (no instructions available) remains a successful load with no body. I added regression coverage for both raw-list and Session trajectories using the exact SDK strings, plus a fixture captured from the real AgentSkills plugin. Matching is anchored to the complete status line so normal skill instructions are not filtered.
| @@ -0,0 +1,741 @@ | |||
| """Skill trajectory parsing helpers. | |||
There was a problem hiding this comment.
Nit (or for follow-up): This is a pretty large monolithic file, and it mixes two different kinds of logic:
- Understanding each harness’s raw trajectory format.
- Deciding what counts as a skill invocation.
Those should be separate stages. Right now, Strands, Claude, Codex, Gemini, and other formats are handled through branches spread across the same functions. After parsing, their information is immediately reduced to InvokedSkill(name, body). At that point we can no longer reliably distinguish:
- A tool call that failed.
- A successful load whose body was not captured.
- Multiple loads of the same skill.
- Loads made by different agents.
- No load attempt at all.
This is why downstream code has to inspect response strings and make assumptions about message ordering.
I'd recommend that we first convert every supported trajectory format into one common representation. For example:
SkillLoadEvent(
name=...,
status="attempted" | "loaded" | "failed",
body=...,
error=...,
call_id=...,
position=...,
agent_id=...,
)
Each harness should have a small adapter whose only job is converting its native messages into these events. The shared extractor and evaluators should operate only on SkillLoadEvent, without knowing whether the source was Strands, Claude, or Codex.
A concrete structure could be:
extractors/skills/
models.py
extractor.py
adapters/
session.py
strands.py
claude.py
codex.py
Each adapter should be tested with an authoritative fixture from that harness. serialize_trajectory should move to evaluator prompt formatting because it is unrelated to trajectory extraction. This structure keeps provider-specific changes isolated and gives each evaluator enough information to apply its own definition of selected, invoked, or followed.
There was a problem hiding this comment.
Thanks, I agree with the separation. I refactored extractors/skills.py into per-harness adapters plus a shared extractor, added the common SkillLoadEvent(name, status, body, error, call_id, position, agent_id) representation, and moved serialize_trajectory into prompt formatting. This preserves failures, uncaptured bodies, repeated loads, ordering, and agent attribution, while letting each evaluator apply its own semantics.
I also added fixtures captured from Strands AgentSkills, Google ADK, Claude Code, and Codex. They exposed and fixed previously unsupported Google GenAI and Codex rollout formats.
Adds the Gemini/Google ADK content-part shapes (functionCall/functionResponse
and the response->result payload nesting), makes result wrappers reachable so a
discovery tool's catalog is found, and matches the noun-first phrasing of a load
acknowledgement ("Skill activated. ...") alongside the verb-first one.
Deduplication now folds a directory alias against the frontmatter name and keeps
the fullest body, so a paged read followed by a full read collapses to one row.
Folding is limited to `_` vs `-` and a later body only wins when it contains the
one already recovered, so two genuinely distinct skills stay separate and stray
tool output cannot displace a real SKILL.md body by being longer.
The evaluator reused one Agent across the per-skill loop, so each judge call appended to the same conversation. The second skill's prompt carried the first verdict as assistant history, and the whole trajectory was resent on top of it: with 5 invoked skills the request payload grew 607k -> 3.0M chars. Build the Agent inside each judge call, matching skill_instruction_following_evaluator, and split the prompt into a per-case context (task, catalog, serialized trajectory) assembled once and a per-decision line, so the trajectory is serialized once instead of once per skill. The existing loop test asserted call_count on a shared mock, which passes either way; it now asserts the constructor count, and a new test asserts each judge receives exactly one prompt naming its own skill.
Experiment.from_dict resolves evaluator_type against a fixed registry, so SkillSelectionAccuracyEvaluator, SkillInstructionFollowingEvaluator and SkillInvoked all raised "Cannot find <name>. Make sure the evaluator type is spelled correctly" on Experiment.from_file, even though to_dict happily wrote them out. Any experiment file using them was unrunnable. Also add shortnames for the two zero-arg judges so `run --evaluator` reaches them in ad-hoc mode. SkillInvoked stays out for the same reason ToolCalled does: it requires a skill_name.
SkillFollowingRating.steps carried min_length=1 and SkillStepRating.step/evidence carried min_length=1. These are structured-output schemas, so a rejected value is not a caught error: the judge is sent back to emit the same answer again. A skill body that prescribes nothing (reference material, frontmatter only) drove 318 model calls and then died with EventLoopException: maximum recursion depth exceeded. Empty `evidence` on a step with no evidence had the same shape. Drop the three floors (steps stays required, just not floored), guard `coverage` against dividing by zero, and treat a no-steps rating as not_applicable in _rating_to_output: nothing was prescribed, so scoring it 0.0 would read as a failure to adhere and 1.0 as vacuous adherence. The prompt now tells the judge to return an empty list rather than invent steps.
The Strands AgentSkills plugin returns its failure and empty-skill cases as plain strings from an @tool function, and @tool reports a plain string return as status="success". So _result_failed saw nothing wrong and the judge was handed "Skill 'x' not found. Available skills: ..." or "Error: skill_name is required..." as the instructions the agent was supposed to follow, on both the raw-list and Session paths. Match those status lines and return body=None. Anchored and matched without DOTALL, like _ACKNOWLEDGEMENT, so a real body that happens to mention a missing file keeps its instructions.
When nothing was invoked and the trajectory advertises no skills, the evaluator still sent the judge a prompt whose available-skills section read "(none listed)" and shipped the verdict as a scored row. A "Yes" credits the agent for declining an offer it never received; a "No" penalizes it for the same. Not every trajectory carries the catalog, so a run with skills genuinely on offer lands here too, and there the right verdict is unknowable rather than favorable. Return a not_applicable row instead, and skip the judge call. An invoked skill is still judged whether or not the catalog was captured: that is a real decision.
…he path
_skill_read_path searched for a read verb and for a SKILL.md path independently
over the whole command, so anything containing both looked like a skill load and
whatever the command printed became the skill body:
cat draft.md > /skills/new/SKILL.md -> InvokedSkill('new', 'col1,col2\n1,2')
sed -i 's/a/b/' /skills/pdf/SKILL.md -> InvokedSkill('pdf', 'ok')
cat data.csv; ls -l /skills/pdf/SKILL.md -> InvokedSkill('pdf', 'col1,col2\n1,2')
Split the command on separators, unwrap `bash -lc "..."`, strip prefixes like
sudo/env/VAR=, drop redirection targets, and require the path to be an operand of
a read verb in that same segment. `sed -i`/`--in-place` is excluded as a write.
Separately, _result_failed vetoed on any nonzero exit code, so exit 141 discarded
a real body: `cat SKILL.md | head -20` is SIGPIPE once head closes the pipe, after
printing the part the agent saw. 141 now counts as usable output; other nonzero
codes still fail.
A load the harness refused was dropped entirely, so a run where the agent asked for the right skill and was refused looked identical to one where it never reached for a skill: the selection judge scored it as an abstention, and could return 1.0 for a decision the agent never made. `InvokedSkill` now carries `status`, defaulting to "loaded". Refused loads are recorded with `status="failed"` and no body on both the raw-list and Session paths. Dedup promotes a skill to "loaded" if any attempt succeeded, so a retry after a refusal is reported as loaded and only an all-refused skill stays failed. Downstream: - the selection judge judges the choice rather than the outcome, and its prompt says the load was refused so a correct pick is not marked wrong for failing - the adherence judge reports the refusal instead of "skill body unavailable", which kept a broken harness looking like a trajectory-capture gap - `SkillInvoked` does not count a refused load as invoked, and says so Two body-mispairing bugs found while adding the status branch: - `_claude_body_after` returned the first injected body after the call index, so parallel Claude Code `Skill` calls all got the first skill's instructions. It now matches the skill against the `Base directory` line, falling back to a lone unmatched body (the directory can alias the frontmatter name) and to None when several are ambiguous. - an unkeyed tool result paired with the next unclaimed skill call regardless of distance, so an unrelated tool's output in between became the skill body. A result now only pairs with the call it directly follows.
The two skill judges each defined their own "not_applicable" string and their own aggregator, and the CLI knew about neither. The same two rows printed 0.50 next to overall: 1.00 because _print_summary averaged an unjudgeable case's placeholder 0.0 while calculate_overall_score dropped it. - NOT_APPLICABLE and EvaluationOutput.not_applicable live in types/evaluation.py, exported from strands_evals.types; both judges import it instead of redefining it. - _aggregate_dropping_na moves to the Evaluator base class. The selection judge now opts in too: it emitted N/A rows but averaged them per case. - EvaluationReport.is_applicable is the single predicate, used by calculate_overall_score, the CLI summary, and the expanded display, which now prints "n/a" rather than a placeholder 0.00 that reads as the worst verdict. - Selection rows carry the judge's rating in label, matching every other judge, and name the decision in reason.
… body Filtering the plugin's status strings out of the body left them reported as status="loaded" with body=None, which is the same row a successful load with an uncaptured body produces. So the mistyped-key case jjbuck raised (requesting pdf-procesing when pdf-processing is registered) still read as a successful invocation, and the adherence judge blamed the agent for not following instructions it never received. _load_refused folds the payload-level refusal in with the status-level one, since @tool marks the plain-string return successful and the text is the only signal. "Skill 'x' activated (no instructions available)" stays loaded: an empty skill is a real load, not a refusal.
… text Per review feedback on the monolithic extractors/skills.py, split it along the seam the per-harness branching already followed: - models.py: what the extractors return (AvailableSkill, InvokedSkill) - adapters.py: the per-harness block shapes. The tool-call and tool-result elif chains become one named recognizer per harness, tried in the same order as before, returning a common ToolCallBlock / ToolResultBlock. - extractor.py: the harness-independent decisions (which calls are skill loads, pairing calls with results, deduplication) and the public entry points. - _patterns.py / _normalize.py: the harness literals and the shared primitives. Public API is unchanged: strands_evals.extractors still exports the same four names. Verified by dumping extraction output over every fixture before and after; the only difference is the new field below. InvokedSkill gains error: str | None, the harness's refusal message on a failed load. The text was recognized and then discarded, so "Skill 'pdf-procesing' not found. Available skills: pdf-processing" (a misspelled name) and "Available skills: (none)" (a harness that mounted nothing) both collapsed into "the load failed". It now reaches the adherence reason, the SkillInvoked reason, and the selection judge's prompt, where a name the harness did not recognize is a worse pick than a right one it could not mount. serialize_trajectory moves to evaluators/prompt_templates, since it is prompt formatting rather than extraction and it is new public API in this branch, so moving it later would be a break.
Every other public function in extractors/ documents its parameters and return value with Args:/Returns: sections. The three functions this PR adds used prose only, so this brings them in line.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 56 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/strands_evals/extractors/skills/adapters/registry.py:61
_tool_callreturnsNoneimmediately when an adapter matches but yields a non-strname or non-dictarguments. Because adapter shapes can overlap (and the registry is explicitly ordered for competing recognizers), this early return prevents later adapters from attempting to parse the same block, potentially dropping valid tool calls.
Instead of return None on type mismatch, continue to the next adapter and only return None after all adapters fail.
def _tool_call(block: dict[str, Any]) -> ToolCallBlock | None:
"""The tool call this block carries, or None if it is not one."""
for adapter in _CALL_ADAPTERS:
matched = adapter(block)
if matched is not None:
call_id, name, arguments = matched
if not isinstance(name, str) or not isinstance(arguments, dict):
return None
return ToolCallBlock(str(call_id) if call_id is not None else None, name, arguments)
return None
Recognizers are tried in order and the first match won, even when that match could
not yield a name and arguments. Shapes overlap, so the same block can be a truncated
call in one shape and a whole one in another: the dual-tagged block the registry
docstring describes carries both a toolUse wrapper and the flat type: "tool_use"
fields, and a truncated wrapper there hid the flat fields that did survive, so a real
skill load read as no load at all. The search now continues past an unusable match.
Continuing is not safe by itself. Every recognizer but _args_call is gated on a tag
naming its harness, so a later match is another reading of the same call. _args_call
matches any block with a string name and a dict args, and only its position at the end
of the registry had kept it away from blocks a specific recognizer already claimed.
Reached after an unusable match it would read those two keys off a block that is not
its shape, so {"toolUse": {...}, "name": "skills", "args": {...}} would report the
sibling pair's skill rather than no call. Naming a skill the agent never asked for is
worse than reporting nothing, so a block that declares a harness is offered only to the
recognizers that read tagged shapes.
Both halves are covered: the recovery test fails without the fallthrough, and the
misparse tests fail if the fallthrough is added without the gate.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 56 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/strands_evals/types/evaluation_report.py:39
is_applicabletreats an emptyoutputslist as applicable (True), but the docstring only discusses the “all rows are not-applicable” case. Since this method is now part of the scoring semantics (overall score / CLI averages), it would help to explicitly document the empty-list behavior so future callers don’t assume[]is dropped as vacuously “all not-applicable”.
tests/strands_evals/evaluators/test_skill_instruction_following_evaluator.py:352- Avoid importing production modules inside the test body. The repo’s guidance is to keep imports at module top so missing dependencies/symbols fail at collection time rather than only when a specific test executes. Here
EvaluationOutputis imported insidetest_aggregator_drops_not_applicable, which is easy to fix by moving it to the existing import block at the top of the file (and then using it directly).
`is_applicable` said a case is dropped when "every row it produced was not-applicable", which is vacuously true of no rows, so the stated rule implied an empty list is dropped while the code keeps it. The code is right: an evaluator that returned no rows failed to judge rather than declining to, `_default_aggregator` scores that `test_pass=False`, and dropping the case would take that failure out of every average and report the run as a clean sweep. Now that this method decides both the corpus score and the per-evaluator CLI averages, the rule it states has to be the rule it applies. Also pins the consequence one level up, where the number is read: a corpus of one empty case and one perfect case averages 0.5, not 1.0. No test covered that, so the misreading could have been "fixed" into the code without anything failing. Test cleanup in the same area: `EvaluationOutput` was imported inside `test_aggregator_drops_not_applicable` rather than at module top, so a missing symbol would surface when that one test ran instead of at collection. Moved up, and the not-applicable literals there now use the `NOT_APPLICABLE` constant.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 56 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/strands_evals/types/evaluation_report.py:44
EvaluationReport.is_applicable()currently treats any case whose outputs are alllabel==NOT_APPLICABLEas non-applicable, even when those rows represent an evaluation failure (test_pass=False), e.g. the skill evaluators’ “no trajectory provided” rows. This causescalculate_overall_score()(and CLI per-evaluator averages) to drop missing-data failures from the mean, which can inflateoverall_scoreand make failures invisible in score-based dashboards.
Consider separating “declined to judge” from “failed to judge”:
- Option A: reserve
NOT_APPLICABLEfor true declines (withtest_pass=True), and for missing-data failures leavelabel=None(or use a distinct label) so they remain applicable and count as 0.0. - Option B: keep the label but refine the applicability/averaging logic (and
_get_label_from_score’s N/A handling) soNOT_APPLICABLE+test_pass=Falserows still count toward averages without being reverse-mapped to a verdict label.
|
@strandly-the-agent review the changes and tell me if there's any gap / improvements, the harness-sdk is here: https://github.com/strands-agents/harness-sdk |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Reviewed 789d2ff (vs merge-base 0d23f47) at poshinchen's request, cross-checked against harness-sdk's real AgentSkills plugin. This is strong work — 21 commits of real self-correction, and most of what I threw at it held up. Verdict: changes requested — 2 blockers, one of which is Copilot's objection, raised three times, still open and still correct.
✅ Verified, not asserted: pytest tests/ → 1872 passed, ruff check + mypy -p src clean. Regenerated strands_agent_skills.json with the PR's own capture script against installed strands-agents 1.50.2 → byte-identical to the checked-in fixture (and that file matches harness-sdk HEAD). Drove the real plugin's 8 result branches through both the raw-message and mapper→Session paths: they agree. XML escaping round-trips, including a </skill> injection attempt, in Python and TypeScript. from strands import Skill is valid at the declared floor strands-agents>=1.42.0. main.
🔴 Blockers
overall_scorereports a failed run as1.00.is_applicabledrops aNOT_APPLICABLErow even whentest_pass=False— exactly what both new evaluators emit for a missing trajectory. Copilot is right; repro inline.- Any read of a file named
SKILL.mdis recorded as an invoked skill, with no check it was ever on offer. Reading this repo's own rootSKILL.mdyields a phantom skillstrands-evalswhose "instructions" are 50k chars of contributor guide — two judge calls, two wrong scores.
🟡 7 should-fix inline. The one I'd look at first: a skill written as a decision tree may be graded on branches the run correctly never entered, and SkillStepRating.status gives the judge no way to say "not applicable to this run". Then a regex that swallows short skill bodies, the harness's metadata tail presented to the judge as SKILL.md instructions, mapper reach, and three mutation-verified test gaps. ⚪ 13 more in the appendix.
Gaps & improvements — the thing you actually asked (8 items, collapsed below). The two I'd act on: LangChain/LangGraph is in #299's harness table and shipped nowhere, and SkillLoadEvent.agent_id is permanently None because both mappers pass metadata={} while the trace carries gen_ai.agent.name — a one-line addition next to the system_prompt fix this PR already makes.
Gaps & improvements — what the harness can do vs. what these evaluators can measure
Read against harness-sdk/strands-py/src/strands/vended_plugins/skills/{agent_skills,skill}.py. Priority: 🅑 block · 🅕 follow-up issue · 🅧 dismissed.
- 🅕 LangChain / LangGraph adapter is absent. #299's selection-signal table lists it alongside Claude Code, Strands and Codex; there is no adapter, fixture or mention. It may work incidentally via
adapters/gemini.py's generic{"name":…, "args":…}fallback, but that's untested and unclaimed. Verifying six harnesses beats claiming a seventh — just say so in the PR body rather than dropping it silently. - 🅕
agent_idis structurally present and permanentlyNone.SkillLoadEvent.agent_idexists and_agent_id_ofreadsspan.metadata(extractor.py:164), but every mapper constructsToolExecutionSpan(..., metadata={}). A real two-agent run confirms the trace does carry the attribution (gen_ai.agent.nameon eachinvoke_agentspan) and the mapper drops it. Since this PR is the one teaching the mapper to carrysystem_prompt, carryinggen_ai.agent.nametoo is a one-line addition — is it in scope? - 🅕 Multi-agent catalog mis-attribution.
_available_from_sessionreturns the firstAgentInvocationSpan.system_promptthat has a block, so in a swarm/graph where sub-agents carry different catalogs, a later sub-agent's pick is judged against the first sub-agent's catalog. Out of #299's stated scope; worth a tracking issue before someone points this at a swarm. - 🅕 Missed activation — the agent that should have loaded a skill and didn't — is unmeasurable except when the case author names the skill up front via
SkillInvoked.c14d88a6removed abstention judging deliberately, and_no_invocation_rowreturns not-applicable with no judge call. Worth being explicit that this is the framework's existing posture, not a skills gap:ToolSelectionAccuracyEvaluatorhas no "should have called a tool" check either. A session-level evaluator, not a re-measurement. - 🅕 No timing / over-activation signal.
SkillLoadEvent.positionis dropped when folding toInvokedSkill, and the selection prompt explicitly tells the judge to score each invocation "on its own merits, not on whether the agent also loaded the other skills". So activating five skills to do one thing, or activating a skill after doing the work by hand, both score the same as a clean pick.extract_skill_load_eventskeepsposition, so the raw material is there. - 🅕 Skill resources are invisible to the extractor.
_list_skill_resourcesadvertisesscripts/,references/,assets/, and real skills routinely say "readreferences/x.md" — following a skill often means reading those. Grep forreferences/scripts/assetsinextractors/skills/returns nothing. The judge can see such a read in the serialized trajectory, but nothing links "the skill advertised it" to "the agent read it", and the truncation above is most likely to eat exactly that region. - 🅕 #299's "invocation rate falls out of the same helper, no judge" didn't ship. Small, and the helper it needs already exists.
- 🅧 Dismissed after checking, so nobody re-opens them:
allowed-toolsenforcement (the harness itself documents it as "not yet enforced",skill.py:222— nothing to measure);strictmode (raises at load time, before a trajectory exists); duplicate/shadowed skill names (harness dedups transparently; eval sees what the agent saw); URL-sourced skills with no filesystem path (extraction never depends on a path).
One thing you might expect me to raise and I won't: the harness keeps an exact ordered activation list in agent.state["agent_skills"]["activated_skills"] (_track_activated_skill), which looks like better ground truth than regexing prompt text. I checked — it isn't available: no mapper touches agent.state, StateEquals reads a field the task author populates, and the route would be Strands-only. The trajectory-based approach is the right generalist default here.
Questions (design / scope — non-blocking except the first)
- Should the
NOT_APPLICABLEmechanism be its own PR? It changes whatoverall_scoremeans for every evaluator, in a 6,100-line skills diff. It's a genuine prerequisite (without it, a correct "no skill needed" run averages in a placeholder0.0), so I'm not arguing for removal — I'm asking whether five new moving parts (a label value, anot_applicableproperty, a publicis_applicable, an opt-in_aggregate_dropping_na, changedcalculate_overall_score) deserve their own review and a decision record perteam/API_BAR_RAISING.md. Note this repo has noneeds-api-reviewlabel; the closest lever isdesign. - Is the drop-NA behaviour label-driven or opt-in? Right now it's both:
calculate_overall_scoredrops NA cases for anyone who emits the literal label, while_aggregate_dropping_nadrops NA rows only if the evaluator assignsself.aggregator. A third-party evaluator emitting the label gets half the semantics and no way to know which half. EvaluationOutput(..., not_applicable=True)is silently accepted and does nothing (verified: pydantic drops the unknown kwarg,labelstaysNone). That's the obvious path for an evaluator author. Would a realEvaluationOutput.not_applicable(reason, ...)classmethod be better — and would it let both evaluators drop their duplicated_not_applicable_rowhelpers?- Does the extractor layer need to be public at all? Six new exported names in
extractors, when the three evaluators are what a user reaches for.ToolCalled's equivalent_check_sessionis private and exports no helper. Under 1.x this asymmetry bites: promoting a private name later is free, withdrawing a public one is a major break — and it's the difference between "we can tighten theSKILL.mdregex in a patch" being yes or no.SkillLoadEventis a 7-fieldNamedTuple, so exporting it freezes field order too. - Two words for one concept in one signature:
extract_selected_skills(...) -> list[InvokedSkill]. Worthextract_invoked_skills? AndSkillInvoked(evaluator) vsInvokedSkill(data) differ only in word order — both public, genuinely confusable. Not exportingInvokedSkillresolves it for free. - What should someone on an unrecognised harness do?
_HARNESS_TOOLSis a private dict inside the library, so the answer today is "open a PR". Defensible while each adapter wants a captured fixture — but the flip side is that a user's own tool namedskillstaking askill_nameargument becomes a phantom skill load with the tool's output as the "skill body" (same root cause as blocker 2; cross-checking against the parsed catalog fixes both).
Appendix — non-blocking (13)
Docstring / comment drift (load-bearing here, because the asides are what a future maintainer trusts)
models.py:41— "the fullest body recovered wins" is not whatextractor.py:145does: the first body is kept, and a later one only replaces it when it is longer and contains the earlier text verbatim. A garbled 72-char first capture beats a legitimate 141-char re-read.extractor.py:1-6— "harness-independent" overstates it:_claude_body_afterandif call.name == "Skill"(extractor.py:541) are Claude-Code-specific and live here, not inadapters/claude.py.adapters/claude.py:7-10already says so; the module docstring and_patterns.py:3-5's "adding a harness is a change to this module and its adapter, not to the traversal logic" should admit the exception._patterns.py:3cites a "design-doc B.1 table" that isn't in the repo — unresolvable for an outside contributor.- PR body is stale in two places: it describes
extractors/skills.py(now a 10-module package), and says the selection evaluator "judges the abstention itself" when no skill was invoked —c14d88a6reversed exactly that, 7 commits and 9 days before head. README/SKILL.md/docstrings were all updated; only the body wasn't.
Small correctness / hygiene
_available_str(skill_selection_accuracy_evaluator.py:60) renders one skill per line, so adescription: |block scalar (which the harness passes through verbatim) splits across lines and the catalog stops being parseable by the judge; an empty description renders as a bare- name:, which reads like a load failure.f"- {s.name}: {s.description}" if s.description else f"- {s.name}"plus a\n→fixes both._canonical_skill_keyfoldsdata-cleananddata_cleaninto one row. Both are loadable in the harness's default lenient mode, so two real skills can collapse to one.cat …/SKILL.md.bakis matched as a skill read (_SKILL_PATHhas no trailing boundary), andsed -ni 's/a/b/' …/SKILL.mdis reported as a read (_SED_IN_PLACErequires whitespace before-i)._skill_name_from_bodycallsSkill.from_content, which emits the harness's ownlogger.warninginto eval output — one line per file-read event, and the "unquoted colon in description" case is common enough that the harness ships a dedicated fallback for it.- The harness's
<available_skills>block also carries<location>for every filesystem skill;AvailableSkilldiscards it. That element is a ready-madeSKILL.mdpath → skill name map, i.e. most of the fix for blocker 2. - An empty catalog is indistinguishable from an un-instrumented trajectory: the harness's literal
<available_skills>\nNo skills are currently available.\n</available_skills>and "no block at all" both parse to[]. _aggregate_dropping_na([])returnsall_pass=Truewhere_default_aggregator([])returnsFalse. Unreachable today; worth aligning while the file is open.test_pass = normalized_score >= 0.75diverges from the>= 0.5used by every other five-point judge (coherence,faithfulness,response_relevance). The inline rationale is sound — prescriptive steps deserve a higher bar — but it silently changes what "pass" means for anyone comparing pass rates across judges in one report, so it belongs in the class docstring, not just a comment. Same file: the docstring claims the scale "mirrors" the existing judges; the numeric mapping does, the labels are new (and better anchored) — worth rewording.- Fixture provenance:
capture_skill_fixtures.py's docstring says the hand-written Gemini CLI / OpenAI Agents / OpenHands shapes "are marked as such" inskill_fixtures.py— they aren't. A reader ofskill_fixtures.py:91-171has no signal that those three formats were never checked against a real harness, unlike the five that were.
How this review was produced
Seven independent fresh-context passes (routing, context-build, correctness, adversarial/differential-vs-harness, API/DevX, test-quality with mutation testing, LLM-context, docs-accuracy, issue-alignment), then de-duplicated and severity-gated on reachability. Repros were run against installed strands-agents 1.50.2 with the real AgentSkills plugin; no live model was invoked anywhere, so every judge-behaviour claim is grounded in the prompt text and the output schema rather than measured — I've flagged that where it matters. Then an independent audit pass re-verified every load-bearing claim and sent six back for correction, which is why two things you might expect to see aren't here: a pass reported that SkillFollowingRating.coverage feeds the score (it doesn't — it only appears in reason), and my first draft of blocker 2 illustrated itself with a repro that didn't reproduce. Both were fixed rather than published.
I'm an experimental agent — treat this as solid work for a human to approve, not a gate. Push back on anything that doesn't hold up.
…odies Review round on strands-agents#330. Five fixes, two of which lose or invert a real result. A case whose every row is not-applicable was dropped from `overall_score` without reading `test_pass`, so the shape both evaluators emit for a missing trajectory (not-applicable, `test_pass=False`) vanished from the mean and a run that never produced a verdict reported as a clean sweep. `actual_trajectory` defaults to None, so one uncaptured trajectory was enough. `test_pass` is what separates declining to judge, which is droppable, from failing to, which is not. The CLI score column and the telemetry label follow from the same predicate and are corrected with it. The acknowledgement and refusal regexes were documented as matching a single line, but their trailing `\s*` crosses newlines, so a body of status line plus one more line was discarded whole: `Skill activated.\nStep 1: do the thing.` lost its step, and a real body whose first line reads like a refusal was additionally reported as a failed load. Both are anchored with `[^\n]*\Z` now. Three smaller ones. The Strands plugin appends `Location:` and friends after a filesystem skill's text, and the prompt labels that whole string "SKILL.md instructions", so the judge could read `Available resources: scripts/extract.py` as a step nobody wrote; the tail is trimmed by those field names rather than by the bare `---`, which is legal Markdown inside real instructions. A read of a file named `SKILL.md` with no parent directory and no frontmatter produced names that are wrong on their face (`.`, `SKILL.md`), and now produces no event. The truncation note now tells the judge that evidence may lie in the gap, since the rubric otherwise offers "skipped" for exactly that absence. Also: a guard so a malformed OpenInference `body` cannot cost the caller a whole span now that the path is walked for every span, and a prompt guideline for decision-tree skills. On a routing skill where the run correctly takes one branch, the judge marked the untaken branches "skipped" in 5 of 5 live runs; the rating was right each time, but the coverage figure reported in `reason` read 0.60 for a flawless run. With the guideline it reads 1.00, also 5 of 5, and the rating is unchanged. Two tests were passing for the wrong reason and are replaced by ones that fail without the code they cover: the body-containment rule (the old test's command used `ls`, which is not a read verb, so no second event was ever created) and the `gen_ai.system_instructions` span-attribute branch, which the SDK writes for Langfuse and bidi sessions and which no test exercised.
Resolves one conflict in the Strands in-memory mapper's `_convert_trace`. Upstream added parent-gap bridging and this branch added the system-prompt backfill at the same point; they operate on the same `converted_spans` list but on different fields, so both are kept with upstream's block first.
|
@strandly-the-agent Thanks, I went through the blockers and should-fix items individually. Most are now addressed, and two I’m leaving out with rationale. Fixed
Decision-tree gradingI also live-checked this against Sonnet 5 five times. The judge did mark untaken branches as skipped, as predicted, but still returned Fully Followed / 1.0 every time. So the proposed 0.5 scoring failure does not reproduce. The actual issue was the diagnostic coverage in reason: it reported 0.60 for a correct conditional execution. A prompt guideline fixes that to 1.00 / 5/5 in 5/5 runs without changing the rating, so I did not add another status enum. Not taking
|
`parse_available_skills` returns [] for two different runs, and the selection prompt rendered both the same way. A harness that mounted no skills advertised that fact; the Strands plugin emits the block with "No skills are currently available." A harness that never records what it offered said nothing at all, which is the Claude Code and Claude Agent SDK case, since neither emits the block and neither exposes the system prompt in its transcript. Rendered as an empty collection, the second run reads to the judge as a claim that no skills existed, and it reasons correctly from that false premise to the wrong verdict: "the agent made an inappropriate selection by invoking a skill that doesn't exist in the available skills pool." Measured on the Claude shape with a correct pick, four runs each: "[]" and "(none listed)" both give No four times, and naming the reason gives Yes four times. The same wording keeps rejecting a genuinely wrong pick, so it does not buy the fix by making the judge agreeable. `advertised_a_catalog` reports whether a block was present at all, which is what lets the two cases render differently: "(none: this harness advertised no skills)" when the harness said so, "(not recorded by this harness)" when it did not say. This also corrects the no-invocation diagnostic, which told a Claude run with sixteen skills available that "no skills were available to select from". Both new prompt tests fail against the previous single-empty rendering.
Resolves one conflict in the OpenInference mapper's `AgentInvocationSpan` construction. Upstream's strands-agents#340 added `metadata=metadata` from `_extract_llm_metadata`; this branch added `system_prompt` so the skill catalog survives mapping. The two set different fields, so both are kept, taking upstream's real metadata over this branch's `{}` placeholder.
Description
A skill is an instruction file (usually
SKILL.md) that the harness offers to an agent at runtime; the agent decides which, if any, to load. This adds evaluation for both halves of that behavior: which skill the agent selected, and whether it then followed the skill's instructions.Parsing helpers (
extractors/skills.py)parse_available_skillsandextract_selected_skillsrecover the skill signals from a trajectory. These are standalone helpers rather than aTraceExtractorlevel because skills have no structured representation in the trace schema: the available set arrives as harness-injected prompt text rather than a span field, and a skill load surfaces as an ordinary tool call. A load is detected either by a reserved skill-tool name plus its skill-name argument, or by a read of a knownSKILL.mdpath. Both helpers accept aSessionor a raw message list, so harnesses without aSessionmapper are supported.Evaluators
SkillSelectionAccuracyEvaluatorjudges whether each invoked skill was an appropriate pick, one output per invoked skill, mirroringToolSelectionAccuracyEvaluator. When no skill was invoked it judges the abstention itself.SkillInstructionFollowingEvaluatorrates how fully each invoked skill's steps were followed on the framework's five-point scale, grounded in per-step covered/partial/skipped evidence.SkillInvokedis a deterministic presence check, the skill analogue ofToolCalled.Prompts live in versioned template subpackages, following the existing convention. Both judges read the trajectory only, so any harness that emits one works:
actual_trajectoryaccepts aSessionor a raw message list.Two supporting fixes
Both session mappers dropped the system prompt when building
AgentInvocationSpan, so anything readingAgentInvocationSpan.system_promptsawNone. This is a bug independent of skills; it surfaced here because the harness advertises its skill catalog in the system prompt. Adds_extract_system_prompthandling both GenAI conventions (gen_ai.system_instructionsand the legacy event form). One note on the OpenInference mapper: span-event message parsing is now unconditional rather than skipped when prompt and response were already recovered from smolagents attributes, because the system prompt lives in those messages._get_messages_from_span_eventsmemoizes perspan_id, so there is no extra parsing cost.calculate_overall_scorenow skips rows whose every output is labelednot_applicable, so an evaluator that cannot apply to a case does not deflate the aggregate with a placeholder 0.0. Behavior is unchanged for every existing evaluator, since none of them emit that label; two regression tests pin this.Declares
pyyaml, used to readSKILL.mdfrontmatter. It was already present transitively viastrands-agents; this makes the direct dependency explicit using the same version bound.Related Issues
#299
Documentation PR
N/A (README and SKILL.md updated in this PR)
Type of Change
New feature
Testing
How have you tested the change? Verify that the changes do not break functionality or introduce new warnings.
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.