Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
.venv/
.venv/evaluators/__pycache__

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 guess you meant this to be two lines to ignore pychache?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

⁠Fixed in ⁠ 8371bc0 ⁠ — ⁠ .venv/ ⁠ and ⁠ pycache/ ⁠ are now on separate lines.

Binary file not shown.
6 changes: 6 additions & 0 deletions evaluators/time_efficiency/evaluator.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: time_efficiency
description: Scores how quickly the agent resolved relative to a time budget
language: python

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.

can you remove this file 'evaluators/bertscore/pycache/bertscore.cpython-314.pyc' ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch! This file was already removed in commit 08b0905. The .gitignore also includes pycache/ so it won't be accidentally committed again.

entrypoint: time_efficiency.py
tags: [performance, time, latency, efficiency, budget]
author: henrikrexed
68 changes: 68 additions & 0 deletions evaluators/time_efficiency/time_efficiency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Community evaluator: time_efficiency

Scores how quickly the agent resolved relative to a time budget.
Uses performance_metrics.duration_s from trace data when available.

Config options:
max_duration_s (float): Time budget in seconds (default: 120)
"""

from agentevals_evaluator_sdk import EvalInput, EvalResult, evaluator


def _extract_duration(inv) -> float | None:
"""Extract duration_s from an invocation's performance_metrics."""
perf = getattr(inv, "performance_metrics", None)
if perf is None and hasattr(inv, "__getitem__"):
try:
perf = inv["performance_metrics"]
except (KeyError, TypeError):
perf = None

if isinstance(perf, dict):
duration = perf.get("duration_s") or perf.get("duration")
if duration is not None:
return float(duration)

return None


@evaluator
def time_efficiency(input: EvalInput) -> EvalResult:
max_duration = input.config.get("max_duration_s", 120.0)

scores: list[float] = []
details_items: list[str] = []
has_data = False

for inv in input.invocations:
duration = _extract_duration(inv)

if duration is None:
# No timing data — assign neutral score
scores.append(0.5)
details_items.append(f"{inv.invocation_id}: no duration data available")
continue

has_data = True
score = max(0.0, min(1.0, 1.0 - (duration / max_duration)))

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.

Please add a guard against 0 values here and in tool_efficiency.

scores.append(score)
details_items.append(
f"{inv.invocation_id}: {duration:.1f}s / {max_duration:.1f}s budget (score: {score:.2f})"
)

overall = sum(scores) / len(scores) if scores else 0.0

return EvalResult(
score=overall,
per_invocation_scores=scores,
details={
"time_details": details_items,
"has_trace_data": has_data,
"max_duration_s": max_duration,
},
)


if __name__ == "__main__":
time_efficiency.run()
6 changes: 6 additions & 0 deletions evaluators/token_efficiency/evaluator.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: token_efficiency
description: Scores how efficiently the agent used tokens relative to a budget
language: python
entrypoint: token_efficiency.py
tags: [performance, tokens, efficiency, budget]
author: henrikrexed
104 changes: 104 additions & 0 deletions evaluators/token_efficiency/token_efficiency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Community evaluator: token_efficiency

Scores how efficiently the agent used tokens relative to a budget.
Uses performance_metrics from trace data when available, falls back to
counting tool calls as a rough proxy.

Config options:
max_tokens (int): Token budget (default: 200000)
weight_input (float): Weight for input tokens in scoring (default: 0.7)
weight_output (float): Weight for output tokens in scoring (default: 0.3)
"""

from agentevals_evaluator_sdk import EvalInput, EvalResult, evaluator


def _extract_tokens(inv) -> dict | None:
"""Extract token counts from an invocation's performance_metrics or metadata."""
# Check performance_metrics (primary source from OTel trace data)
perf = getattr(inv, "performance_metrics", None)
if perf is None and hasattr(inv, "__getitem__"):
try:
perf = inv["performance_metrics"]
except (KeyError, TypeError):
perf = None

if isinstance(perf, dict):
input_t = perf.get("input_tokens") or perf.get("prompt_tokens")
output_t = perf.get("output_tokens") or perf.get("completion_tokens")
if input_t is not None or output_t is not None:
return {
"input_tokens": int(input_t or 0),
"output_tokens": int(output_t or 0),
}

# Check performance_budget on invocation (eval_set integration)
budget = getattr(inv, "performance_budget", None)
if budget is None and hasattr(inv, "__getitem__"):
try:
budget = inv["performance_budget"]
except (KeyError, TypeError):
pass

# No token data available
return 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.

Can you confirm that this is dead code?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

⁠Confirmed, it was dead code. The ⁠ performance_budget ⁠ block extracted the value but never used it — removed in ⁠ 8371bc0 ⁠.



@evaluator
def token_efficiency(input: EvalInput) -> EvalResult:
max_tokens = input.config.get("max_tokens", 200000)
weight_input = input.config.get("weight_input", 0.7)
weight_output = input.config.get("weight_output", 0.3)

scores: list[float] = []
details_items: list[str] = []
has_data = False

for inv in input.invocations:
tokens = _extract_tokens(inv)

if tokens is None:
# No token data — score based on tool call count as rough proxy
# More tool calls ≈ more tokens used
tool_count = len(inv.intermediate_steps.tool_calls) if inv.intermediate_steps else 0
if tool_count == 0:
scores.append(0.5) # No data, neutral score
details_items.append(f"{inv.invocation_id}: no token data available")
else:
# Rough heuristic: assume ~5000 tokens per tool call
estimated = tool_count * 5000
score = max(0.0, min(1.0, 1.0 - (estimated / max_tokens)))
scores.append(score)
details_items.append(
f"{inv.invocation_id}: estimated ~{estimated} tokens from {tool_count} tool calls"
)
continue

has_data = True
input_t = tokens["input_tokens"]
output_t = tokens["output_tokens"]
weighted_total = (input_t * weight_input) + (output_t * weight_output)
weighted_budget = max_tokens * 1.0 # Budget applies to weighted total

score = max(0.0, min(1.0, 1.0 - (weighted_total / weighted_budget)))

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.

Maybe we should go with a separate max_input and max_output so max will behave like maximum instead of weighted sum?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point — separate ⁠ max_input_tokens ⁠ and ⁠ max_output_tokens ⁠ would be clearer and more intuitive. The weighted approach tried to capture that input tokens cost less than output tokens (prefill vs generation), but expressing it as two separate budgets is simpler to reason about:

⁠ yaml
config:
max_input_tokens: 150000
max_output_tokens: 50000
 ⁠

Score becomes: ⁠ min(input_score, output_score) ⁠ where each is ⁠ 1.0 - (actual / max) ⁠ clamped to [0,1]. An agent that blows either budget gets penalized.

This also aligns better with how LLM providers price their APIs (separate input/output rates). I'll rework the evaluator. Should I keep ⁠ max_tokens ⁠ as a single-budget fallback for backwards compatibility, or go clean with only ⁠ max_input_tokens ⁠ / ⁠ max_output_tokens ⁠?

scores.append(score)
details_items.append(
f"{inv.invocation_id}: {input_t} input + {output_t} output = "
f"{input_t + output_t} total (weighted: {weighted_total:.0f}/{weighted_budget:.0f})"
)

overall = sum(scores) / len(scores) if scores else 0.0

return EvalResult(
score=overall,
per_invocation_scores=scores,
details={
"token_details": details_items,
"has_trace_data": has_data,
"max_tokens": max_tokens,
},
)


if __name__ == "__main__":
token_efficiency.run()
6 changes: 6 additions & 0 deletions evaluators/tool_efficiency/evaluator.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: tool_efficiency
description: Scores whether the agent used tools effectively — penalizes waste, duplicates, and errors
language: python
entrypoint: tool_efficiency.py
tags: [performance, tools, efficiency, budget]
author: henrikrexed
114 changes: 114 additions & 0 deletions evaluators/tool_efficiency/tool_efficiency.py

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.

Please return NOT_EVALUATED when it makes sense to keep it consistent with other evaluators.

Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Community evaluator: tool_efficiency

Scores whether the agent used tools effectively. Penalizes duplicate calls
(same tool + same args), error calls, and budget overruns.

Config options:
max_tool_calls (int): Tool call budget (default: 15)
penalize_duplicates (bool): Penalize repeated identical calls (default: true)
penalize_errors (bool): Penalize failed tool calls (default: true)
"""

import json
from agentevals_evaluator_sdk import EvalInput, EvalResult, evaluator


def _call_signature(call) -> str:
"""Create a hashable signature for a tool call (name + sorted args)."""
name = call.get("name", "") if isinstance(call, dict) else getattr(call, "name", "")

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.

Can you please just use attribute access to match the codebase conventions?

args = call.get("args", {}) if isinstance(call, dict) else getattr(call, "args", {})
try:
args_str = json.dumps(args, sort_keys=True, default=str)
except (TypeError, ValueError):
args_str = str(args)
return f"{name}::{args_str}"


def _is_error_response(response) -> bool:
"""Check if a tool response indicates an error."""
output = response.get("output", "") if isinstance(response, dict) else getattr(response, "output", "")
output_str = str(output).lower()
# Check common error indicators
if any(marker in output_str for marker in ["error", "failed", "exception", "traceback"]):

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 think we have to be more sophisticated here, as you can have these substrings in perfectly fine outputs as well.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is still relevant, right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, still relevant . ⁠ _is_error_response ⁠ is used by ⁠ tool_efficiency ⁠ to detect failed tool calls when ⁠ penalize_errors=true ⁠. It checks the tool response output for common error markers (⁠ error ⁠, ⁠ failed ⁠, ⁠ exception ⁠, ⁠ traceback ⁠) and the ⁠ status ⁠ field. This is a heuristic since there's no standardized error field in the current tool response format. If the SDK adds a formal error/status field to tool responses in the future, we could use that instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in ⁠ 4e9899d ⁠ — removed the text-based heuristic entirely. ⁠ _is_error_response ⁠ now only checks the structured ⁠ status ⁠ field for ⁠ error ⁠/⁠ failed ⁠/⁠ failure ⁠. No more false positives from output text.

return True
status = response.get("status", "") if isinstance(response, dict) else getattr(response, "status", "")
if str(status).lower() in ("error", "failed"):
return True
return False


@evaluator
def tool_efficiency(input: EvalInput) -> EvalResult:
max_tool_calls = input.config.get("max_tool_calls", 15)
penalize_duplicates = input.config.get("penalize_duplicates", True)
penalize_errors = input.config.get("penalize_errors", True)

scores: list[float] = []
details_items: list[str] = []

for inv in input.invocations:
tool_calls = inv.intermediate_steps.tool_calls if inv.intermediate_steps else []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the first time to use fields not part of the standard ADK Invocation format, we'll have to think a bit about how to go about these.

tool_responses = (
inv.intermediate_steps.tool_responses if inv.intermediate_steps else []
)
total = len(tool_calls)

if total == 0:
scores.append(1.0) # No tools needed = perfectly efficient
details_items.append(f"{inv.invocation_id}: no tool calls (score: 1.0)")
continue
Comment on lines +45 to +52

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.

Not sure if we should return a perfect score here. Many times zero tool means a failure. We also have tool_coverage to check for minimum tool usage.

Maybe we should make this configurable?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point — zero tool calls often means the agent hallucinated an answer instead of using its tools. Returning 1.0 here is misleading.

I'd suggest adding a ⁠ min_tool_calls ⁠ config (default 0 for backward compat). When set, zero calls scores 0.0 instead of 1.0. And when ⁠ min_tool_calls=0 ⁠ (explicitly "tools are optional"), zero calls still scores 1.0.

⁠ yaml
config:
max_tool_calls: 15
min_tool_calls: 1 # 0 = tools optional, >0 = penalize no-tool runs
 ⁠

This keeps ⁠ tool_efficiency ⁠ focused on efficiency while letting users opt into "tools are required". For strict "did the agent use tools at all" checks, ⁠ tool_coverage ⁠ is the right evaluator — they complement each other.


# Count duplicates
seen_signatures: dict[str, int] = {}
duplicate_count = 0
for call in tool_calls:
sig = _call_signature(call)
seen_signatures[sig] = seen_signatures.get(sig, 0) + 1

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.

Can we move this into the conditional below to avoid unnecessary work?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

⁠Good call — no need to compute signatures at all when ⁠ penalize_duplicates ⁠ is disabled. Will move the signature counting inside the conditional. Fixed in next push.


if penalize_duplicates:
duplicate_count = sum(count - 1 for count in seen_signatures.values() if count > 1)

# Count errors
error_count = 0
if penalize_errors and tool_responses:
for resp in tool_responses:
if _is_error_response(resp):
error_count += 1

# Calculate useful calls
wasted = duplicate_count + error_count
useful = max(0, total - wasted)

# Efficiency ratio: useful / total
efficiency_ratio = useful / total if total > 0 else 1.0

# Budget penalty: how much over budget
budget_overrun = max(0, total - max_tool_calls) / max_tool_calls
budget_factor = max(0.0, 1.0 - budget_overrun)

score = max(0.0, min(1.0, efficiency_ratio * budget_factor))
scores.append(score)

parts = [f"total={total}", f"useful={useful}"]
if duplicate_count > 0:
parts.append(f"duplicates={duplicate_count}")
if error_count > 0:
parts.append(f"errors={error_count}")
if total > max_tool_calls:
parts.append(f"over_budget={total - max_tool_calls}")
details_items.append(f"{inv.invocation_id}: {', '.join(parts)} (score: {score:.2f})")

overall = sum(scores) / len(scores) if scores else 0.0

return EvalResult(
score=overall,
per_invocation_scores=scores,
details={
"tool_details": details_items,
"max_tool_calls": max_tool_calls,
},
)


if __name__ == "__main__":
tool_efficiency.run()