-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add token_efficiency, tool_efficiency, time_efficiency evaluators #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
08b0905
8371bc0
4e9899d
beadc5c
c5d1684
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| .venv/ | ||
| .venv/evaluators/__pycache__ | ||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you remove this file 'evaluators/bertscore/pycache/bertscore.cpython-314.pyc' ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 | ||
| 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))) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add a guard against 0 values here and in |
||
| 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() | ||
| 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 |
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you confirm that this is dead code?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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))) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we should go with a separate max_input and max_output so max will behave like maximum instead of weighted sum?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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() | ||
| 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 |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please return |
| 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", "") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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"]): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we have to be more sophisticated here, as you can have these substrings in perfectly fine outputs as well.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is still relevant, right?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 [] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we move this into the conditional below to avoid unnecessary work?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess you meant this to be two lines to ignore pychache?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8371bc0 — .venv/ and pycache/ are now on separate lines.