Skip to content

feat: add ToolEfficiencyEvaluator for session-level tool usage analysis - #362

Open
max-rattray-aws wants to merge 2 commits into
strands-agents:mainfrom
max-rattray-aws:feat/tool-efficiency-evaluator
Open

feat: add ToolEfficiencyEvaluator for session-level tool usage analysis#362
max-rattray-aws wants to merge 2 commits into
strands-agents:mainfrom
max-rattray-aws:feat/tool-efficiency-evaluator

Conversation

@max-rattray-aws

Copy link
Copy Markdown
Contributor

Summary

Adds a ToolEfficiencyEvaluator that operates at SESSION_LEVEL and classifies each tool call in a trajectory as NECESSARY, REDUNDANT, ERRORED, or UNNECESSARY. The efficiency score is necessary_count / total_count (1.0 if no tool calls were made).

This complements ToolSelectionAccuracyEvaluator (was calling a tool justified at this point?) and ToolParameterAccuracyEvaluator (were the parameters correct?) by answering a different question: given the whole trajectory, were all these calls needed?

Usage

from strands_evals.evaluators import ToolEfficiencyEvaluator

evaluator = ToolEfficiencyEvaluator()

# With a specific model and truncation limit for long tool results
evaluator = ToolEfficiencyEvaluator(
    model="us.anthropic.claude-sonnet-4-20250514",
    max_tool_result_length=1000,
)

The evaluator returns an EvaluationOutput with:

  • score: efficiency ratio (0.0 to 1.0)
  • test_pass: True if score >= 0.5
  • reason: overall assessment from the judge
  • label: JSON string with per-call classifications for programmatic analysis

What's tested

  • Initialization with defaults and custom values
  • All four classification categories (NECESSARY, REDUNDANT, ERRORED, UNNECESSARY)
  • Score computation edge cases (perfect efficiency, zero efficiency, no tool calls)
  • Prompt formatting (tool list inclusion, result truncation, error display)
  • Error handling (invalid trajectory type, missing trajectory)
  • Serialization (to_dict, ToolEfficiencyRating JSON roundtrip)
  • Agent invocation (correct model, structured output model passed)

21 unit tests, all passing.

Related to #345

Add a new LLM-judge evaluator that classifies each tool call in a
trajectory as NECESSARY, REDUNDANT, ERRORED, or UNNECESSARY. The
efficiency score is computed as necessary_count / total_count.

The evaluator operates at SESSION_LEVEL, reading the full conversation
trajectory and producing a structured breakdown of tool call efficiency.
This complements ToolSelectionAccuracyEvaluator (per-call) and
ToolParameterAccuracyEvaluator (per-call parameters) by providing a
global view of tool usage waste.

Key features:
- Per-call classification with reasoning
- Configurable max_tool_result_length for context window management
- JSON-serialized breakdown in EvaluationOutput.label
- Versioned prompt template following existing patterns
@max-rattray-aws
max-rattray-aws requested a review from a team as a code owner August 10, 2026 17:10
@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 strands-running labels Aug 10, 2026
return [
EvaluationOutput(
score=score,
test_pass=score >= 0.5,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Suggestion): The pass threshold 0.5 is hardcoded. For an efficiency ratio, "half the calls were necessary = pass" is a fairly arbitrary cutoff, and callers may reasonably want a stricter bar.

Suggestion: Consider exposing it as a constructor argument (e.g. pass_threshold: float = 0.5) so the behavior is configurable and self-documenting. If it's intentionally fixed, a brief comment explaining the rationale would help.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved — pass_threshold is now a constructor argument (defaulting to 0.5), and test_evaluate_low_efficiency exercises a custom 0.8 threshold. Thanks!

result = evaluator_agent(prompt, structured_output_model=ToolEfficiencyRating)
rating = cast(ToolEfficiencyRating, result.structured_output)

score = rating.necessary_count / rating.total_count if rating.total_count > 0 else 1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Important): The score uses the LLM-provided rating.necessary_count / rating.total_count rather than deriving them from the classifications list. Since both counts and the per-call breakdown are produced independently by the model, they can silently disagree — the score and the label (classifications JSON) may tell different stories. There's also nothing preventing necessary_count > total_count, which would yield a score > 1.0. (Notably, test_evaluate_low_efficiency encodes exactly this drift: 3 classifications but total_count=5.)

Suggestion: Derive the counts deterministically from classifications so the score always matches the breakdown, e.g.:

total = len(rating.classifications)
necessary = sum(1 for c in rating.classifications if c.category == ToolCallCategory.NECESSARY)
score = necessary / total if total > 0 else 1.0

This keeps necessary_count/total_count as the model's summary while making the score authoritative and bounded to [0, 1].

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Confirmed resolved in the latest push — the score is now derived directly from classifications (necessary_count = sum(... == NECESSARY), total_count = len(classifications)), and the redundant necessary_count/total_count fields were dropped from ToolEfficiencyRating entirely. Nice — that removes the drift risk and the score > 1.0 case, and the new test_evaluate_score_derived_from_classifications locks in the behavior.

One tiny follow-up: the class docstring still says "Score is calculated as necessary_count / total_count", which now refers to values that aren't model fields anymore. Consider rewording to reference the classification counts to keep it in sync.

assert len(result) == 1
assert result[0].score == 1.0
assert result[0].test_pass is True
assert result[0].reason == "All tool calls contributed to the final response."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Suggestion): These evaluate tests assert on individual EvaluationOutput fields (score, test_pass, reason) separately. Per-field assertions silently miss unexpected or regressed fields on the returned object.

Suggestion: Where the output is fully deterministic, assert the whole object in one equality check, e.g. assert result == [EvaluationOutput(score=1.0, test_pass=True, reason=..., label=...)]. This catches regressions in any field, including label.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved — the evaluate tests now assert the full EvaluationOutput in a single equality check (including label), which will catch regressions in any field. Thanks for tightening these up.

@github-actions

Copy link
Copy Markdown

Assessment: Comment

Clean, well-tested addition that follows the existing evaluator conventions (versioned prompt template, SESSION_LEVEL extraction, structured output via strands.Agent) and slots in nicely alongside the other tool evaluators. My main concern is that the reported score is taken from LLM-provided counts rather than derived from the classification list, which lets the score and the label breakdown drift apart.

Review themes
  • Correctness/consistency: Score should be derived from classifications so it stays consistent with the per-call breakdown and bounded to [0, 1].
  • API design: Hardcoded 0.5 pass threshold could be configurable; new public class + 3 pydantic models may warrant the needs-api-review label.
  • Docs: New evaluator missing from the README "Available Evaluators" list.
  • Prompt/schema: Category casing in the prompt doesn't match the lowercase enum values.
  • Testing: Solid coverage of all four categories and edge cases; consider full-object assertions over per-field ones.

Nice complement to the existing tool-selection/parameter evaluators — the four-category framing is intuitive.

@github-actions

Copy link
Copy Markdown

Following up on my earlier review — one item from the summary is still outstanding (I couldn't attach it inline since README.md isn't part of this PR's diff):

Issue (Important): The new ToolEfficiencyEvaluator is exported from strands_evals.evaluators but isn't listed in the README's Available Evaluators → Session-Level Evaluators section (which currently shows only GoalSuccessRateEvaluator). The repo convention is to document every public evaluator there for discoverability.

Suggestion: Add a ToolEfficiencyEvaluator bullet under that subsection, and optionally a short usage snippet alongside the existing tool-evaluator examples.

Everything else from the review looks great — score is now derived from classifications, pass_threshold is configurable, and the tests assert full objects (23 passing locally). 🎉

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.

1 participant