Skip to content

feat: add skill-level evaluators for skill-equipped agents - #330

Open
sangminwoo wants to merge 25 commits into
strands-agents:mainfrom
sangminwoo:skill-evaluators
Open

feat: add skill-level evaluators for skill-equipped agents#330
sangminwoo wants to merge 25 commits into
strands-agents:mainfrom
sangminwoo:skill-evaluators

Conversation

@sangminwoo

Copy link
Copy Markdown
Collaborator

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_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.

Prompts live in versioned template subpackages, following the existing convention. Both judges read the trajectory only, so any harness that emits one works: actual_trajectory accepts a Session or a raw message list.

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. Adds _extract_system_prompt handling both GenAI conventions (gen_ai.system_instructions and 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_events memoizes per span_id, so there is no extra parsing cost.

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. It was already present transitively via strands-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.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@sangminwoo
sangminwoo requested a review from a team as a code owner July 28, 2026 00:09
@sangminwoo
sangminwoo requested review from notowen333 and a lite review from Copilot July 28, 2026 00:09
@github-actions github-actions Bot added enhancement New feature or request area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics labels Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 both Session objects and raw message lists, plus trajectory serialization with truncation.
  • Add skill evaluators: SkillSelectionAccuracyEvaluator, SkillInstructionFollowingEvaluator, and deterministic SkillInvoked, with versioned prompt templates and unit tests.
  • Fix mapper/system plumbing: backfill AgentInvocationSpan.system_prompt in Strands + OpenInference mappers; update overall score aggregation to exclude fully-not_applicable rows; declare pyyaml dependency.

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_trajectory is None, 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 explicit None check and return a not_applicable row with test_pass=False (consistent with SkillSelectionAccuracyEvaluator).
    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=None currently falls through to "no skill invoked" (passing) because extract_selected_skills(None) returns []. Add an explicit None check and return a failing not_applicable row.
    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 a not_applicable row when parse_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.

Comment thread src/strands_evals/evaluators/skill_instruction_following_evaluator.py Outdated
Comment thread src/strands_evals/evaluators/skill_selection_accuracy_evaluator.py Outdated
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.
Copilot AI review requested due to automatic review settings July 28, 2026 00:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 new Agent per invoked skill. Reusing a single Agent instance 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_skills currently skips reserved skill-tool invocations when the tool call has no matching toolResult. 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 with body=None when 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 new Agent inside 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 one Agent once 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)

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.

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.

@sangminwoo sangminwoo Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/strands_evals/extractors/skills.py Outdated


def _body_from_result(result: Any) -> str | None:
"""Return actual skill instructions, excluding errors and load acknowledgements."""

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.

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?

@sangminwoo sangminwoo Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/strands_evals/extractors/skills.py Outdated
@@ -0,0 +1,741 @@
"""Skill trajectory parsing helpers.

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.

Nit (or for follow-up): This is a pretty large monolithic file, and it mixes two different kinds of logic:

  1. Understanding each harness’s raw trajectory format.
  2. 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_call returns None immediately when an adapter matches but yields a non-str name or non-dict arguments. 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.
Copilot AI review requested due to automatic review settings August 4, 2026 22:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_applicable treats an empty outputs list 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 EvaluationOutput is imported inside test_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.
Copilot AI review requested due to automatic review settings August 4, 2026 22:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 all label==NOT_APPLICABLE as non-applicable, even when those rows represent an evaluation failure (test_pass=False), e.g. the skill evaluators’ “no trajectory provided” rows. This causes calculate_overall_score() (and CLI per-evaluator averages) to drop missing-data failures from the mean, which can inflate overall_score and make failures invisible in score-based dashboards.

Consider separating “declined to judge” from “failed to judge”:

  • Option A: reserve NOT_APPLICABLE for true declines (with test_pass=True), and for missing-data failures leave label=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) so NOT_APPLICABLE+test_pass=False rows still count toward averages without being reverse-mapped to a verdict label.

@poshinchen

Copy link
Copy Markdown
Contributor

@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 strandly-the-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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. ⚠️ The branch is CONFLICTING with main.

🔴 Blockers

  1. overall_score reports a failed run as 1.00. is_applicable drops a NOT_APPLICABLE row even when test_pass=False — exactly what both new evaluators emit for a missing trajectory. Copilot is right; repro inline.
  2. Any read of a file named SKILL.md is recorded as an invoked skill, with no check it was ever on offer. Reading this repo's own root SKILL.md yields a phantom skill strands-evals whose "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.

  1. 🅕 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.
  2. 🅕 agent_id is structurally present and permanently None. SkillLoadEvent.agent_id exists and _agent_id_of reads span.metadata (extractor.py:164), but every mapper constructs ToolExecutionSpan(..., metadata={}). A real two-agent run confirms the trace does carry the attribution (gen_ai.agent.name on each invoke_agent span) and the mapper drops it. Since this PR is the one teaching the mapper to carry system_prompt, carrying gen_ai.agent.name too is a one-line addition — is it in scope?
  3. 🅕 Multi-agent catalog mis-attribution. _available_from_session returns the first AgentInvocationSpan.system_prompt that 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.
  4. 🅕 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. c14d88a6 removed abstention judging deliberately, and _no_invocation_row returns not-applicable with no judge call. Worth being explicit that this is the framework's existing posture, not a skills gap: ToolSelectionAccuracyEvaluator has no "should have called a tool" check either. A session-level evaluator, not a re-measurement.
  5. 🅕 No timing / over-activation signal. SkillLoadEvent.position is dropped when folding to InvokedSkill, 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_events keeps position, so the raw material is there.
  6. 🅕 Skill resources are invisible to the extractor. _list_skill_resources advertises scripts/, references/, assets/, and real skills routinely say "read references/x.md" — following a skill often means reading those. Grep for references/scripts/assets in extractors/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.
  7. 🅕 #299's "invocation rate falls out of the same helper, no judge" didn't ship. Small, and the helper it needs already exists.
  8. 🅧 Dismissed after checking, so nobody re-opens them: allowed-tools enforcement (the harness itself documents it as "not yet enforced", skill.py:222 — nothing to measure); strict mode (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)
  1. Should the NOT_APPLICABLE mechanism be its own PR? It changes what overall_score means 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 placeholder 0.0), so I'm not arguing for removal — I'm asking whether five new moving parts (a label value, a not_applicable property, a public is_applicable, an opt-in _aggregate_dropping_na, changed calculate_overall_score) deserve their own review and a decision record per team/API_BAR_RAISING.md. Note this repo has no needs-api-review label; the closest lever is design.
  2. Is the drop-NA behaviour label-driven or opt-in? Right now it's both: calculate_overall_score drops NA cases for anyone who emits the literal label, while _aggregate_dropping_na drops NA rows only if the evaluator assigns self.aggregator. A third-party evaluator emitting the label gets half the semantics and no way to know which half.
  3. EvaluationOutput(..., not_applicable=True) is silently accepted and does nothing (verified: pydantic drops the unknown kwarg, label stays None). That's the obvious path for an evaluator author. Would a real EvaluationOutput.not_applicable(reason, ...) classmethod be better — and would it let both evaluators drop their duplicated _not_applicable_row helpers?
  4. 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_session is 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 the SKILL.md regex in a patch" being yes or no. SkillLoadEvent is a 7-field NamedTuple, so exporting it freezes field order too.
  5. Two words for one concept in one signature: extract_selected_skills(...) -> list[InvokedSkill]. Worth extract_invoked_skills? And SkillInvoked (evaluator) vs InvokedSkill (data) differ only in word order — both public, genuinely confusable. Not exporting InvokedSkill resolves it for free.
  6. What should someone on an unrecognised harness do? _HARNESS_TOOLS is 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 named skills taking a skill_name argument 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 what extractor.py:145 does: 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_after and if call.name == "Skill" (extractor.py:541) are Claude-Code-specific and live here, not in adapters/claude.py. adapters/claude.py:7-10 already 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:3 cites 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 — c14d88a6 reversed 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 a description: | 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_key folds data-clean and data_clean into one row. Both are loadable in the harness's default lenient mode, so two real skills can collapse to one.
  • cat …/SKILL.md.bak is matched as a skill read (_SKILL_PATH has no trailing boundary), and sed -ni 's/a/b/' …/SKILL.md is reported as a read (_SED_IN_PLACE requires whitespace before -i).
  • _skill_name_from_body calls Skill.from_content, which emits the harness's own logger.warning into 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; AvailableSkill discards it. That element is a ready-made SKILL.md path → 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([]) returns all_pass=True where _default_aggregator([]) returns False. Unreachable today; worth aligning while the file is open.
  • test_pass = normalized_score >= 0.75 diverges from the >= 0.5 used 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" in skill_fixtures.py — they aren't. A reader of skill_fixtures.py:91-171 has 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.

Comment thread src/strands_evals/types/evaluation_report.py Outdated
Comment thread src/strands_evals/extractors/skills/extractor.py
Comment thread src/strands_evals/extractors/skills/_patterns.py Outdated
Comment thread src/strands_evals/evaluators/prompt_templates/trajectory_prompt_template.py Outdated
Comment thread src/strands_evals/mappers/openinference_session_mapper.py
Comment thread tests/strands_evals/types/test_evaluation_report.py
Comment thread README.md Outdated
…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.
@sangminwoo

sangminwoo commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@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

  • is_applicable blocker: Confirmed. Applicability now distinguishes declining to judge (test_pass=True, droppable) from failing to judge (test_pass=False, retained), so a missing trajectory can no longer disappear from overall_score. CLI and telemetry use the same predicate.
  • \s* regex bug: Confirmed. The regexes could swallow multiline skill bodies and even misclassify a body beginning with refusal-like text as a failed load. Both are now line-bounded.
  • Harness metadata leakage: Reproduced with the AgentSkills fixture. The appended Location/resource metadata is now removed by field name rather than splitting on bare ---, which can be valid skill Markdown.
  • Unnamed SKILL.md reads: Reads that would resolve to . or SKILL.md no longer produce skill events.
  • Truncation guidance: The prompt now tells the judge that relevant evidence may fall inside the omitted middle section.
  • Mutation-test gaps: Replaced both tests that were passing for the wrong reason and verified that the intended mutations now fail.

Decision-tree grading

I 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

  • Require file-read skills to appear in the catalog: I don’t think we can make this a hard requirement because Codex does not emit <available_skills> at all. Doing so would disable its file-read detection entirely. I kept the safe subset of the fix above. The residual false-positive case is real, but across 3,379 Tessl + SkillsBench trajectories from three agent models I found 0 false positives. I also documented that callers should inspect parse_available_skills before relying on the score. I think fully solving this needs a catalog-optional design rather than gating on catalog presence.
  • Span-cache re-keying: Confirmed that this behavior predates and is untouched by this PR. I’d prefer to keep it as a separate change rather than expand this PR further.

`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.
@yonib05
yonib05 requested review from opieter-aws and removed request for notowen333 August 13, 2026 00:29
@opieter-aws
opieter-aws requested review from jjbuck and poshinchen and removed request for opieter-aws August 13, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants