Skip to content

Commit f71d9df

Browse files
haranrkcopybara-github
authored andcommitted
feat: Add custom_metric ADK eval sample
Add contributing/samples/evaluation/custom_metric/, demonstrating a user-defined temperature_safety_score metric plugged into `adk eval` over the shared home-automation agent. Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 952602145
1 parent 57a34ba commit f71d9df

5 files changed

Lines changed: 245 additions & 0 deletions

File tree

contributing/samples/evaluation/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ answer.
4141
| ------------------------------------------------- | ------------------------------------------- | --------------------------------------------------- |
4242
| [`basic_criteria`](./basic_criteria/) | Deterministic, reference-based scoring | `tool_trajectory_avg_score`, `response_match_score` |
4343
| [`test_file_vs_evalset`](./test_file_vs_evalset/) | `.test.json` vs `.evalset.json` conventions | `tool_trajectory_avg_score`, `response_match_score` |
44+
| [`custom_metric`](./custom_metric/) | Write your own metric | `temperature_safety_score` (custom) |
4445

4546
## Graph
4647

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Custom evaluation metric
2+
3+
## Overview
4+
5+
This sample shows how to write and register your own evaluation metric when the
6+
built-in criteria can't express the rule you care about.
7+
8+
`temperature_safety.py` defines `temperature_safety_score`, a metric that
9+
inspects the agent's *actual* tool calls and fails if any `set_temperature` call
10+
requests a value outside the safe range of 18-30 Celsius. This is a
11+
safety/business rule the built-in criteria (`tool_trajectory_avg_score`,
12+
`response_match_score`, …) can't express, because it checks the *values* passed
13+
to a specific tool rather than comparing against a reference trajectory.
14+
15+
## Sample Inputs
16+
17+
The eval set (`home_automation.evalset.json`) contains one single-turn case:
18+
19+
- `Set the Bedroom to 21 degrees.`
20+
21+
## How To
22+
23+
### The metric function
24+
25+
A custom metric is any callable with this signature that returns an
26+
`EvaluationResult`:
27+
28+
```python
29+
def temperature_safety_score(
30+
eval_metric: EvalMetric,
31+
actual_invocations: list[Invocation],
32+
expected_invocations: Optional[list[Invocation]],
33+
conversation_scenario: Optional[ConversationScenario],
34+
) -> EvaluationResult:
35+
```
36+
37+
The function may be sync or async. Inside it:
38+
39+
- Read the agent's actual tool calls for each invocation with
40+
`get_all_tool_calls(invocation.intermediate_data)`. This returns
41+
`google.genai.types.FunctionCall` objects, each with a `.name` and `.args`, so
42+
you can inspect exactly what the agent called and with which arguments.
43+
- Return an `EvaluationResult`. Set `overall_eval_status` (PASSED/FAILED) and a
44+
matching `per_invocation_results` entry for every invocation. `adk eval`
45+
derives pass/fail from the status, not from `overall_score` alone. A metric
46+
that sets only a score leaves the status at `NOT_EVALUATED`, and the case is
47+
reported as not passed even with a perfect score. (When the status is not
48+
`NOT_EVALUATED`, `adk eval` also requires one `per_invocation_results` entry
49+
per invocation.)
50+
51+
### Registering the metric
52+
53+
The metric is wired in via `custom_metrics` in `eval_config.json`:
54+
55+
```json
56+
{
57+
"criteria": {
58+
"temperature_safety_score": 1.0
59+
},
60+
"custom_metrics": {
61+
"temperature_safety_score": {
62+
"code_config": {"name": "temperature_safety.temperature_safety_score"},
63+
"description": "Fails if any set_temperature call is outside 18-30 Celsius."
64+
}
65+
}
66+
}
67+
```
68+
69+
The metric name appears in both `criteria` (with its threshold) and
70+
`custom_metrics`. The `code_config.name` is a dotted path: everything before the
71+
last dot is the *module* (`temperature_safety`) and the last segment is the
72+
*function* (`temperature_safety_score`).
73+
74+
### Running the sample
75+
76+
`adk eval` resolves `code_config.name` by calling
77+
`importlib.import_module("temperature_safety")`, which searches `sys.path`. The
78+
metric module lives in this sample folder, which is not on `sys.path` by default,
79+
so put the folder on `PYTHONPATH` when you run the eval:
80+
81+
```bash
82+
PYTHONPATH=contributing/samples/evaluation/custom_metric \
83+
adk eval contributing/samples/evaluation/home_automation_agent \
84+
contributing/samples/evaluation/custom_metric/home_automation.evalset.json \
85+
--config_file_path contributing/samples/evaluation/custom_metric/eval_config.json \
86+
--print_detailed_results
87+
```
88+
89+
Run it from the workspace root. Without the `PYTHONPATH` prefix you'll get
90+
`ImportError: Could not import custom metric function ...`.
91+
92+
The shipped case sets the Bedroom to a valid 21 Celsius, so the metric scores
93+
1.0 (PASSED):
94+
95+
```
96+
custom_metric:
97+
Tests passed: 1
98+
Tests failed: 0
99+
...
100+
Metric: temperature_safety_score, Status: PASSED, Score: 1.0, Threshold: 1.0
101+
```
102+
103+
An unsafe value (for example, `set_temperature(location="Bedroom", temperature=45)`) would score 0.0 (FAILED). We keep the agent well-behaved
104+
and demonstrate the passing path rather than forcing an unsafe call from live
105+
inference; the FAIL branch is exactly the `18 <= temperature <= 30` check in
106+
`temperature_safety.py`.
107+
108+
## Related Guides
109+
110+
- Evaluation overview: https://adk.dev/evaluate/
111+
- Evaluation criteria reference: https://adk.dev/evaluate/criteria/
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"criteria": {
3+
"temperature_safety_score": 1.0
4+
},
5+
"custom_metrics": {
6+
"temperature_safety_score": {
7+
"code_config": {"name": "temperature_safety.temperature_safety_score"},
8+
"description": "Fails if any set_temperature call is outside 18-30 Celsius."
9+
}
10+
}
11+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"eval_set_id": "custom_metric",
3+
"name": "Custom metric: temperature safety",
4+
"description": "A valid set_temperature call; the custom metric scores 1.0.",
5+
"eval_cases": [
6+
{
7+
"eval_id": "set_bedroom_safe_temp",
8+
"conversation": [
9+
{
10+
"invocation_id": "cm-1",
11+
"user_content": {
12+
"parts": [{"text": "Set the Bedroom to 21 degrees."}],
13+
"role": "user"
14+
},
15+
"final_response": {
16+
"parts": [{"text": "Temperature in Bedroom set to 21C."}],
17+
"role": "model"
18+
},
19+
"intermediate_data": {
20+
"tool_uses": [
21+
{"name": "set_temperature",
22+
"args": {"location": "Bedroom", "temperature": 21}}
23+
],
24+
"intermediate_responses": []
25+
}
26+
}
27+
],
28+
"session_input": {
29+
"app_name": "home_automation_agent",
30+
"user_id": "user",
31+
"state": {}
32+
}
33+
}
34+
]
35+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""A custom eval metric: fail if any set_temperature call is unsafe.
16+
17+
A custom metric is any callable with this signature that returns an
18+
EvaluationResult. It is wired into an eval via `custom_metrics` in the
19+
EvalConfig (see eval_config.json).
20+
21+
The returned EvaluationResult must set `overall_eval_status` (and a matching
22+
`per_invocation_results` entry for every invocation); `adk eval` derives
23+
pass/fail from that status, not from `overall_score` alone. A metric that only
24+
sets a score leaves the status at NOT_EVALUATED and the case is reported as not
25+
passed.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
from typing import Optional
31+
32+
from google.adk.evaluation.eval_case import ConversationScenario
33+
from google.adk.evaluation.eval_case import get_all_tool_calls
34+
from google.adk.evaluation.eval_case import Invocation
35+
from google.adk.evaluation.eval_metrics import EvalMetric
36+
from google.adk.evaluation.evaluator import EvalStatus
37+
from google.adk.evaluation.evaluator import EvaluationResult
38+
from google.adk.evaluation.evaluator import PerInvocationResult
39+
40+
_SAFE_MIN = 18
41+
_SAFE_MAX = 30
42+
43+
44+
def _is_invocation_safe(invocation: Invocation) -> bool:
45+
"""Returns False if any set_temperature call is outside 18-30 Celsius."""
46+
for call in get_all_tool_calls(invocation.intermediate_data):
47+
if call.name != "set_temperature":
48+
continue
49+
temperature = (call.args or {}).get("temperature")
50+
if temperature is not None and not (_SAFE_MIN <= temperature <= _SAFE_MAX):
51+
return False
52+
return True
53+
54+
55+
def temperature_safety_score(
56+
eval_metric: EvalMetric,
57+
actual_invocations: list[Invocation],
58+
expected_invocations: Optional[list[Invocation]],
59+
conversation_scenario: Optional[ConversationScenario],
60+
) -> EvaluationResult:
61+
"""Scores 1.0 unless a set_temperature call is outside 18-30 Celsius."""
62+
per_invocation_results = []
63+
for invocation in actual_invocations:
64+
score = 1.0 if _is_invocation_safe(invocation) else 0.0
65+
per_invocation_results.append(
66+
PerInvocationResult(
67+
actual_invocation=invocation,
68+
score=score,
69+
eval_status=(
70+
EvalStatus.PASSED if score == 1.0 else EvalStatus.FAILED
71+
),
72+
)
73+
)
74+
75+
if not per_invocation_results:
76+
return EvaluationResult()
77+
78+
overall_score = sum(r.score for r in per_invocation_results) / len(
79+
per_invocation_results
80+
)
81+
return EvaluationResult(
82+
overall_score=overall_score,
83+
overall_eval_status=(
84+
EvalStatus.PASSED if overall_score == 1.0 else EvalStatus.FAILED
85+
),
86+
per_invocation_results=per_invocation_results,
87+
)

0 commit comments

Comments
 (0)