Repository navigation
Description
🔴 Required Information
Is your feature request related to a specific problem?
An eval case with a conversation_scenario (simulated user) has no deterministic check on tool use. tool_trajectory_avg_score and tool_parameter_match both need expected_invocations, which a simulated conversation does not have. They raise expected_invocations is needed by this metric., LocalEvalService records the metric as NOT_EVALUATED, and NOT_EVALUATED does not affect the case's status.
So when the agent calls a wrong or destructive tool in a simulated conversation, only a judge-backed metric can notice. The snippet below runs offline on main at ab63844 (2.14.0, Python 3.12.7). The agent calls delete_account in a "cancel my order" scenario:
final_eval_status: NOT_EVALUATED
tool_trajectory_avg_score: NOT_EVALUATED
tool_parameter_match: NOT_EVALUATED
Raising without expected_invocations is correct for those two metrics. This request is for a metric that does not need them.
Describe the Solution You'd Like
A built-in, deterministic metric that checks the tool calls of a whole conversation against expectations stored on the scenario (names open to discussion):
required: calls that must appear somewhere in the conversation.forbidden: calls that must not appear.- Arguments are optional. When given, they are compared with a
ToolArgsMatcher, as intool_parameter_match.
Score: 0.0 if any forbidden call appears, otherwise the fraction of required calls that were matched (each actual call matches at most one). A scenario without expectations is NOT_EVALUATED.
Impact on your work
I found this gap while reading the evaluation code for other contributions, not from a production need, so there is no deadline. A check like "the agent must call cancel_order for this order and must never call delete_account" is one a CI gate should be able to make without depending on a judge model's verdict or cost.
Willingness to contribute
Yes. I would like to agree on the design here first.
🟡 Recommended Information
Describe Alternatives You've Considered
rubric_based_tool_use_quality_v1/multi_turn_tool_use_quality_v1: they work without a reference, but need a judge model, so the verdict is not reproducible and costs model calls.- A custom metric (
custom_metrics): it receives theconversation_scenario, but there is nowhere to put per-case expectations.ConversationScenarioforbids extra fields, and the evaluator does not receive theEvalCase. - Expectations on the criterion in
EvalConfig: they would apply to every case, while required calls differ per case.
Proposed API / Implementation
class ToolCallExpectation(EvalBaseModel):
name: str
args: dict[str, Any] | None = None # None: any arguments
class ToolCallExpectations(EvalBaseModel):
required: list[ToolCallExpectation] = []
forbidden: list[ToolCallExpectation] = []
class ConversationScenario(EvalBaseModel):
...
tool_call_expectations: ToolCallExpectations | None = None
class ToolCallExpectationsCriterion(BaseCriterion):
args_matcher: ToolArgsMatcher | None = None
in_order: bool = False # required calls must appear in the listed order- Putting the expectations on
ConversationScenarioneeds no change toEvaluator.evaluate_invocations, which already receives the scenario. - Result shape as recommended in Support conversation-level evaluators that don't require per-invocation results #4533: the score goes on the last invocation, earlier ones are
NOT_EVALUATED.
Questions for maintainers:
- Is a built-in metric like this welcome, or is tool use in simulated conversations meant to stay judge-only?
- Should the expectations live on
ConversationScenarioor onEvalCase(which would also cover static conversations, but needs the evaluator to receive them)? - Is "any forbidden call scores 0.0" the right rule, or should forbidden calls be a separate metric?
Additional Context
Reproduction (no API key needed):
import asyncio
import logging
from google.adk.agents.llm_agent import LlmAgent
from google.adk.evaluation import base_eval_service as svc
from google.adk.evaluation import eval_case as ec
from google.adk.evaluation.conversation_scenarios import ConversationScenario
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
from google.adk.evaluation.local_eval_service import LocalEvalService
from google.genai import types
logging.disable(logging.CRITICAL)
async def main():
manager = InMemoryEvalSetsManager()
manager.create_eval_set("app", "set")
scenario = ConversationScenario(
starting_prompt="Cancel my order.",
conversation_plan="Ask the agent to cancel order 1.",
)
manager.add_eval_case(
"app", "set", ec.EvalCase(eval_id="c1", conversation_scenario=scenario)
)
# What a simulated conversation could produce: a wrong, destructive call.
call = types.Part.from_function_call(name="delete_account", args={})
actual = ec.Invocation(
user_content=types.UserContent("Cancel my order."),
final_response=types.ModelContent("Done."),
intermediate_data=ec.InvocationEvents(
invocation_events=[
ec.InvocationEvent(
author="agent", content=types.ModelContent(parts=[call])
)
]
),
)
service = LocalEvalService(
root_agent=LlmAgent(name="agent", model="fake"),
eval_sets_manager=manager,
)
request = svc.EvaluateRequest(
inference_results=[
svc.InferenceResult(
app_name="app",
eval_set_id="set",
eval_case_id="c1",
inferences=[actual],
session_id="s",
status=svc.InferenceStatus.SUCCESS,
)
],
evaluate_config=svc.EvaluateConfig(
eval_metrics=[
EvalMetric(metric_name="tool_trajectory_avg_score", threshold=1.0),
EvalMetric(metric_name="tool_parameter_match", threshold=1.0),
]
),
)
async for result in service.evaluate(request):
print("final_eval_status:", result.final_eval_status.name)
for metric in result.overall_eval_metric_results:
print(f" {metric.metric_name}: {metric.eval_status.name}")
asyncio.run(main())
Activity
sattyamjjain commentedon Nov 11, 2026
+1 on the gap. The criteria doc shows tool_trajectory_avg_score with "No" under Supports User Simulation (https://github.com/google/adk-docs/blob/main/docs/evaluate/criteria.md).
On Q2, one data point: per-case data already reaches an evaluator through EvalCase.rubrics. The same doc says, "The merged rubric list passed to the judge is the union of the criterion-level list above and any type-matching entries from EvalCase.rubrics." May be worth tracing how that is wired before adding a field to ConversationScenario, since a scenario-level field would only cover simulated cases.
One edge for forbidden tools: decide whether it matches calls the model emitted or calls that actually ran. Per the callbacks doc, if before_tool_callback returns a dict, "the tool's run_async method is skipped", and "an empty dict counts as an override". So with a runtime guard in place, a forbidden call can be emitted and never executed. I think counting the emitted function_call is right, since that is the model behaviour the eval is testing.
Fruiterer333 commentedon Nov 11, 2026
Thanks, both points are useful.@sattyamjjain
On Q2: I traced how
EvalCase.rubricsreaches the evaluator.LocalEvalServicecopies them onto every actualInvocation(_copy_eval_case_rubrics_to_actual_invocations), so the evaluator reads them frominvocation.rubrics. Putting tool-call expectations onEvalCasethe same way would need a new field onInvocation, and would share the copying side effect reported in #7874. Putting them onConversationScenarioavoids both, but only covers simulated cases, as you say. I'd leave that trade-off to the maintainers.On forbidden calls: agreed, the metric should count the
function_calls the model emitted, not the calls that actually ran. That is also whattool_trajectory_avg_scoreandtool_parameter_matchread fromintermediate_data, and it is the model behaviour the eval is testing.