Skip to content

tool_trajectory_avg_score fails for confirmation-gated tools because the framework adk_request_confirmation call is counted as an agent tool call #8025

Closed

Description

@jayanti-prajapati

🔴 Required Information

Describe the Bug:
When an agent uses a tool with require_confirmation=True, the evaluation trajectory includes the framework-generated adk_request_confirmation function call alongside the model's real tool calls. get_all_tool_calls() (src/google/adk/evaluation/eval_case.py:239) collects every function_call part in invocation_events and does not exclude ADK's client function calls (CLIENT_FUNCTION_CALL_NAMES in src/google/adk/utils/_function_call_names.py). As a result:

  • tool_trajectory_avg_score with match_type: EXACT scores a correct trajectory 0.0, even with ignore_args: true.
  • tool_call_count_v1 counts the confirmation request as a tool call.

Steps to Reproduce:

  1. Run the minimal reproduction below with pytest (no API key needed; a scripted BaseLlm plays the model).
  2. The same agent, script and eval case are run with require_confirmation=False and require_confirmation=True.

Expected Behavior:
Both runs pass. The model called get_order then cancel_order in both, which matches the expected trajectory. adk_request_confirmation is emitted by the framework, not chosen by the model.

Observed Behavior:

  • require_confirmation=False: PASSED
  • require_confirmation=True: FAILED. tool_trajectory_avg_score for orders_agent Failed. Expected 1.0, but got 0.0. The actual tool calls are get_order, cancel_order, adk_request_confirmation(originalFunctionCall=..., toolConfirmation={... 'confirmed': False}).

The same happens with adk eval and a live Gemini model (gemini-3.5-flash): actual trajectory get_order → cancel_order → adk_request_confirmation, tool_trajectory_avg_score: FAILED (0.0), tool_call_count_v1: 3.

Environment Details:

  • ADK Library Version: 2.14.0 (main at dd7161f)
  • Desktop OS: macOS 14.7 (Darwin 23.6.0)
  • Python Version: 3.11

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: scripted BaseLlm (repro); gemini-3.5-flash (live check)

🟡 Optional Information

Regression: Unknown.

Additional Context:

  • The other client function calls (adk_request_credential, adk_request_input) are collected by the same code path, so auth- and input-gated tools are probably affected too. Not verified at runtime.
  • Precedent for treating these names as framework calls: src/google/adk/cli/api_server.py:769 (_ADK_RESERVED_FUNCTION_NAMES) and src/google/adk/a2a/agent/_remote_a2a_agent.py:129.
  • Possible fix: skip names in CLIENT_FUNCTION_CALL_NAMES in get_all_tool_calls() (and the matching responses in get_all_tool_responses()), or in the trajectory/efficiency evaluators. Happy to send a PR with unit tests if the approach sounds right.
  • Related limitation (separate issue if useful): inference stops at the confirmation pause (actual_response is empty), and an eval case has no way to supply the confirmation decision, so post-approval behaviour can't be evaluated.
  • Related: tool_trajectory_avg_score returns 0.0 even when tool name and args match exactly #5410 / fix(eval): include function-call events in invocation_events when skip_summarization is set #5417 (function-call events in invocation_events).

Minimal Reproduction Code:


"""Offline repro: a confirmation-gated tool breaks tool_trajectory_avg_score.

No API key needed: a scripted BaseLlm plays the model.

The same agent, the same scripted model and the same eval case are run twice:
- require_confirmation=False -> trajectory [get_order, cancel_order] -> PASS
- require_confirmation=True  -> ADK adds its own `adk_request_confirmation`
  function call to the actual trajectory -> FAIL (EXACT, ignore_args=True)

Run:
  pytest -q test_confirmation_trajectory.py
"""

import os
from typing import AsyncGenerator

from google.adk.agents import LlmAgent
from google.adk.evaluation.agent_evaluator import AgentEvaluator
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.tools.function_tool import FunctionTool
from google.genai import types
import pytest

HERE = os.path.dirname(os.path.abspath(__file__))


class ScriptedModel(BaseLlm):
  """Returns pre-written responses in order."""

  model: str = "scripted"
  script: list[types.Content]
  calls: int = 0

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    content = self.script[min(self.calls, len(self.script) - 1)]
    self.calls += 1
    yield LlmResponse(content=content)


def _call(name: str, args: dict) -> types.Content:
  return types.Content(
      role="model",
      parts=[types.Part(function_call=types.FunctionCall(name=name, args=args))],
  )


def get_order(order_id: str) -> dict:
  """Looks up an order."""
  return {"id": order_id, "status": "processing"}


def cancel_order(order_id: str) -> dict:
  """Cancels an order."""
  return {"id": order_id, "status": "cancelled"}


def _build_agent(require_confirmation: bool) -> LlmAgent:
  model = ScriptedModel(
      script=[
          _call("get_order", {"order_id": "A-100"}),
          _call("cancel_order", {"order_id": "A-100"}),
          types.Content(role="model", parts=[types.Part(text="Order A-100 is cancelled.")]),
      ]
  )
  return LlmAgent(
      name="orders_agent",
      model=model,
      instruction="Help with orders.",
      tools=[
          get_order,
          FunctionTool(cancel_order, require_confirmation=require_confirmation),
      ],
  )


@pytest.mark.asyncio
@pytest.mark.parametrize("require_confirmation", [False, True])
async def test_trajectory_with_and_without_confirmation(require_confirmation):
  # Expected trajectory in data/cancel.test.json: get_order, cancel_order.
  # test_config.json: tool_trajectory_avg_score EXACT, ignore_args=True.
  await AgentEvaluator.evaluate(
      agent_module=_build_agent(require_confirmation),
      eval_dataset_file_path_or_dir=os.path.join(HERE, "data", "cancel.test.json"),
      num_runs=1,
  )

cancel.test.json

  "eval_set_id": "confirmation_trajectory_repro",
  "eval_cases": [
    {
      "eval_id": "cancel_order",
      "conversation": [
        {
          "invocation_id": "inv-1",
          "user_content": {"role": "user", "parts": [{"text": "Cancel order A-100."}]},
          "final_response": {"role": "model", "parts": [{"text": "Order A-100 is cancelled."}]},
          "intermediate_data": {
            "tool_uses": [
              {"name": "get_order", "args": {"order_id": "A-100"}},
              {"name": "cancel_order", "args": {"order_id": "A-100"}}
            ],
            "intermediate_responses": []
          }
        }
      ],
      "session_input": {"app_name": "orders_agent", "user_id": "user", "state": {}}
    }
  ]
}

test_config.json

{
  "criteria": {
    "tool_trajectory_avg_score": {"threshold": 1.0, "match_type": "EXACT", "ignore_args": true}
  }
}

How often has this issue occurred?: Always (100%).

Activity

  1. Anoshor commented on Nov 7, 2026

    @Anoshor
    Contributor

    Hi, I'd like to work on this. The fix I have in mind is to have get_all_tool_calls() skip the framework's client function calls (CLIENT_FUNCTION_CALL_NAMES), so the trajectory and tool-count evaluators only see calls the model chose, plus a unit test covering a confirmation-gated tool. I'll open a PR shortly.

  2. added a commit that references this issue on Nov 7, 2026
    b9a5370
  3. self-assigned this
    on Nov 9, 2026
  4. added theissue type on Nov 9, 2026
  5. added a commit that references this issue on Nov 10, 2026
    825a374
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions