Skip to content

human_in_loop samples: reimburse runs without an approval; the model can call it first or after a rejection #7987

Open

Description

@trackoor

Required Information

Re-checked at filing: main moved to 70e1cbd since 36cb10d (one commit, six files, none of the files cited below).

Describe the Bug:

The three human-in-the-loop reimbursement samples present an approval flow, but nothing in them enforces it. contributing/samples/hitl/human_in_loop/agent.py registers reimburse as a plain function tool next to LongRunningFunctionTool(func=ask_for_approval) (line 53) and tells the model "If the manager approves, you will call reimburse()" (lines 48-51). The README calls the updated FunctionResponse with {'status': 'approved', ...} the "Crucial Step" (README.md lines 15-20) and says the agent then proceeds to reimburse (line 48). The order is only in the instruction:

  • reimburse (agent.py lines 23-27) returns {'status': 'ok'} for any arguments; it has no require_confirmation, no before_tool_callback, and reads no state, so the framework runs it whenever the model calls it, in the first turn or after a rejection;
  • ask_for_approval (lines 30-38) returns pending and never records anything, and the later "approved" message is a client-authored FunctionResponse (main.py lines 112-126) that no code ties to the reimburse call that follows: the model may call reimburse without it, with a rejected one, or for a different amount;
  • the YAML variant contributing/samples/hitl/tool_human_in_the_loop_config (tools.py lines 20-24, root_agent.yaml lines 27-31) and the A2A remote agent contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/agent.py (lines 23-27, 53) carry the same code.

The framework already has the gate the samples need: FunctionTool(require_confirmation=...) (src/google/adk/tools/function_tool.py lines 408-426 and 506-531) pauses the call with adk_request_confirmation until the application answers with a ToolConfirmation, and the sibling samples hitl/tool_confirmation/agent.py (lines 27-38, 55) and hitl/human_tool_confirmation/agent.py (lines 90-93, on a reimburse tool) use it. #1851 reported the same behavior on this sample with a live model in 2025 and was closed by pointing at that feature; the sample still ships without it.

Steps to Reproduce:

  1. Clone the repository at 36cb10d and install it (uv pip install -e . or the pinned requirements).
  2. Save the attached poc.py (it imports the samples from contributing/samples/hitl and the framework from src, and replaces the model with a scripted BaseLlm).
  3. Run python poc.py <repo root>.
  4. Read the two SUMMARY lines: the reimburse body ran in every scenario, including "no approval" and "rejected".

Expected Behavior:

A reimburse call for an amount that needs a manager runs only after the manager approved that ticket. A call without an ask_for_approval round, or after a rejected response, is held or refused by code, not by the instruction.

Observed Behavior:

As shipped, both the Python sample and the YAML variant pay in all three scenarios (S1: the model calls reimburse(500) at once; S2: ask_for_approval(500), manager answers rejected, model calls reimburse(500); S3: the same with approved):

=== hitl/human_in_loop (agent.py): tools = ['reimburse', 'ask_for_approval']
   S1 no approval: model calls reimburse(500) at once, never ask_for_approval
      [reimbursement_agent] call reimburse({'purpose': 'travel', 'amount': 500.0})
      [reimbursement_agent] resp reimburse -> {'status': 'ok'}
   S1 no approval result: reimburse ran 1 time(s), held/rejected 0 time(s)
   S2 rejected: model calls ask_for_approval(500); manager answers 'rejected'; model calls reimburse(500)
      [reimbursement_agent] call ask_for_approval({'purpose': 'travel', 'amount': 500.0}) [long-running]
      [reimbursement_agent] resp ask_for_approval -> {'status': 'pending', 'amount': 500.0, 'ticketId': 'reimbursement-ticket-001'}
      <client> manager rejected: sending FunctionResponse for ask_for_approval id=fc-ask-1
      [reimbursement_agent] call reimburse({'purpose': 'travel', 'amount': 500.0})
      [reimbursement_agent] resp reimburse -> {'status': 'ok'}
   S2 rejected result: reimburse ran 1 time(s), held/rejected 0 time(s)
...
SUMMARY (times the reimburse body ran per scenario):
   hitl/human_in_loop (agent.py): {'S1 no approval': 1, 'S2 rejected': 1, 'S3 approved': 1}
   hitl/tool_human_in_the_loop_config (root_agent.yaml): {'S1 no approval': 1, 'S2 rejected': 1, 'S3 approved': 1}

The scripted model only makes the call order explicit; #1851 shows a live model producing S1 from a user message that claims the policy changed.

Environment Details:

  • ADK Library Version (pip show google-adk): 2.13.0, run from the source tree at 36cb10d
  • Desktop OS: macOS
  • Python Version (python -V): 3.12.12

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: a scripted BaseLlm subclass (no model call); the samples name gemini-2.5-flash

Optional Information

Regression:

Not a regression: the samples have never had a gate on reimburse (#1851 observed the same in 2025 on the then contributing/samples/human_in_loop).

Logs:

Full output of poc.py as shipped and on the patched copy is in the attachment; the excerpts above are verbatim.

Screenshots / Video:

Not applicable.

Additional Context:

Suggested fix (suggested_fix.diff below, applies to main at 36cb10d): keep the sample's long-running ask_for_approval as the way the manager's decision enters, and put the framework's own confirmation between the model and the payout.

  • hitl/human_in_loop/agent.py: FunctionTool(reimburse, require_confirmation=needs_manager_approval) with needs_manager_approval(purpose, amount) = amount >= 100, the threshold the instruction already states (the same shape as hitl/human_tool_confirmation/agent.py lines 90-93).
  • hitl/human_in_loop/main.py: answers the adk_request_confirmation pause with ToolConfirmation(confirmed=...), True only after the simulated manager approved the ticket, False for a reimburse the model issued in the first turn. The README gets one step describing the gate.
  • hitl/tool_human_in_the_loop_config/tools.py: the same gate inside reimburse with tool_context.request_confirmation() (the idiom of hitl/tool_confirmation/agent.py lines 27-38), because the YAML route cannot express it through constructor args: FunctionTool.from_config (src/google/adk/tools/base_tool.py lines 212-291) leaves require_confirmation out of the kwargs for a Union[bool, Callable] parameter and only logs. Executed on 36cb10d with a YAML tool name: FunctionTool, args: {func: ..., require_confirmation: true}: WARNING:google_adk.google.adk.tools.base_tool:Unsupported parsing for argument: require_confirmation. and the resulting tool has _require_confirmation = False. That is worth its own look; the in-tool check sidesteps it.
  • The A2A remote agent has the same code and the same fix shape; the diff leaves it out because there the pause travels over A2A (see A2A (default executor, streaming): RemoteA2aAgent receives the final reply and the adk_request_confirmation pause twice #7247 and A2aAgentExecutor reports a tool awaiting human confirmation as INPUT_REQUIRED, not AUTH_REQUIRED (A2A spec 7.6) #7810), which I did not exercise.

With the patch the same script holds S1 and S2 and still pays S3:

   S1 no approval: model calls reimburse(500) at once, never ask_for_approval
      [reimbursement_agent] call reimburse({'purpose': 'travel', 'amount': 500.0})
      [reimbursement_agent] call adk_request_confirmation({'originalFunctionCall': {'id': 'fc-reimburse-1', ...}}) [long-running]
      [reimbursement_agent] resp reimburse -> {'error': 'This tool call requires confirmation, please approve or reject.'}
      <client> framework asks to confirm reimburse(); manager decision=None, answering confirmed=False
      [reimbursement_agent] resp reimburse -> {'error': 'This tool call is rejected.'}
   S1 no approval result: reimburse ran 0 time(s), held/rejected 2 time(s)
...
SUMMARY (times the reimburse body ran per scenario):
   hitl/human_in_loop (agent.py): {'S1 no approval': 0, 'S2 rejected': 0, 'S3 approved': 1}
   hitl/tool_human_in_the_loop_config (root_agent.yaml): {'S1 no approval': 0, 'S2 rejected': 0, 'S3 approved': 1}

The patched main.py, driven by the same scripted model through its own main(), pays the $50 request without a pause, and for the $200 request pauses reimburse after the simulated approval and pays once the confirmation is answered (For 'reimburse', Response: {'status': 'ok'}).

The gate is safe for calls the model issues next to reimburse in the same turn: on this commit, a plain sibling tool proposed in the same model turn as a require_confirmation tool ran once and the confirmed tool ran once, with a plain Runner, with App(resumability_config=ResumabilityConfig(is_resumable=True)), and with the approval delivered twice (measured with a separate script, sibling count 1 in all three modes), so confirming the held reimburse does not replay the rest of the turn.

I am happy to send the fix as a pull request (CLA signed), with the A2A variant included if you want the same gate there.

Minimal Reproduction Code:

See the attached poc.py (runs the samples' own root_agent through google.adk.runners.Runner with an InMemorySessionService and a scripted BaseLlm; no API key).

# the sequence the scripted model plays in S2, against the unmodified sample
model.script.append(call("ask_for_approval", "fc-ask-1", purpose="travel", amount=500.0))
await client.turn(types.Content(role="user", parts=[types.Part(text="Please reimburse $500 for travel")]))
update = await client.manager_decides("rejected")   # FunctionResponse id=fc-ask-1, {"status": "rejected"}
model.script.append(call("reimburse", "fc-reimburse-1", purpose="travel", amount=500.0))
await client.turn(update)                             # -> resp reimburse -> {'status': 'ok'}

How often has this issue occurred?:

suggested_fix.diff (apply with git apply at the commit named above)
--- a/contributing/samples/hitl/human_in_loop/agent.py
+++ b/contributing/samples/hitl/human_in_loop/agent.py
@@ -15,6 +15,7 @@
 from typing import Any
 
 from google.adk import Agent
+from google.adk.tools.function_tool import FunctionTool
 from google.adk.tools.long_running_tool import LongRunningFunctionTool
 from google.adk.tools.tool_context import ToolContext
 from google.genai import types
@@ -38,6 +39,19 @@
   }
 
 
+def needs_manager_approval(purpose: str, amount: float) -> bool:
+  """Holds reimburse() for confirmation when the amount needs a manager.
+
+  The instruction asks the model to call ask_for_approval() first, but nothing
+  makes the model do so: it can call reimburse() right away, or after the
+  manager rejected the ticket. With this predicate the framework pauses every
+  reimburse() call of $100 or more (adk_request_confirmation) until the
+  application confirms it, and the application confirms only a ticket the
+  manager approved (see main.py).
+  """
+  return amount >= 100
+
+
 root_agent = Agent(
     name='reimbursement_agent',
     instruction="""
@@ -50,6 +64,9 @@
       call reimburse() to reimburse the amount to the employee. If the manager
       rejects, you will inform the employee of the rejection.
 """,
-    tools=[reimburse, LongRunningFunctionTool(func=ask_for_approval)],
+    tools=[
+        FunctionTool(reimburse, require_confirmation=needs_manager_approval),
+        LongRunningFunctionTool(func=ask_for_approval),
+    ],
     generate_content_config=types.GenerateContentConfig(temperature=0.1),
 )
--- a/contributing/samples/hitl/human_in_loop/main.py
+++ b/contributing/samples/hitl/human_in_loop/main.py
@@ -23,6 +23,7 @@
 from google.adk.runners import Runner
 from google.adk.sessions.in_memory_session_service import InMemorySessionService
 from google.adk.tools.long_running_tool import LongRunningFunctionTool
+from google.adk.tools.tool_confirmation import ToolConfirmation
 from google.genai import types
 from opentelemetry import trace
 from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
@@ -48,6 +49,47 @@
       session_service=session_service,
   )
 
+  async def answer_confirmation(
+      confirmation_call: types.FunctionCall, manager_approved: bool
+  ):
+    """Answers the framework's pause on reimburse() (adk_request_confirmation).
+
+    reimburse() is gated with require_confirmation, so a call the manager did
+    not approve is held here and rejected; only an approved ticket lets it run.
+    """
+    print(
+        f"--- Framework asks to confirm reimburse() (call ID"
+        f" {confirmation_call.id}); manager approved: {manager_approved} ---"
+    )
+    async for event in runner.run_async(
+        session_id=session.id,
+        user_id=USER_ID,
+        new_message=types.Content(
+            role="user",
+            parts=[
+                types.Part(
+                    function_response=types.FunctionResponse(
+                        id=confirmation_call.id,
+                        name=confirmation_call.name,
+                        response=ToolConfirmation(
+                            confirmed=manager_approved
+                        ).model_dump(),
+                    )
+                )
+            ],
+        ),
+    ):
+      if event.content and event.content.parts:
+        for i, part in enumerate(event.content.parts):
+          if part.text:
+            print(f"    Part {i} [Text]: {part.text.strip()}")
+          if part.function_response:
+            print(
+                f"    Part {i} [FunctionResponse]: For"
+                f" '{part.function_response.name}', Response:"
+                f" {part.function_response.response}"
+            )
+
   async def call_agent(query: str):
     content = types.Content(role="user", parts=[types.Part(text=query)])
 
@@ -61,6 +103,7 @@
     long_running_function_call: types.FunctionCall | None = None
     initial_tool_response: types.FunctionResponse | None = None
     ticket_id: str | None = None
+    confirmation_call: types.FunctionCall | None = None
 
     async for event in events_async:
       if event.content and event.content.parts:
@@ -73,7 +116,9 @@
                 f" {part.function_call.name}({part.function_call.args}) ID:"
                 f" {part.function_call.id}"
             )
-            if not long_running_function_call and part.function_call.id in (
+            if part.function_call.name == "adk_request_confirmation":
+              confirmation_call = part.function_call
+            elif not long_running_function_call and part.function_call.id in (
                 event.long_running_tool_ids or []
             ):
               long_running_function_call = part.function_call
@@ -102,6 +147,11 @@
 
     print("--- End of agent's initial turn ---\n")
 
+    if confirmation_call:
+      # The model called reimburse() for an amount that needs a manager
+      # without an approved ticket: reject the held call.
+      await answer_confirmation(confirmation_call, manager_approved=False)
+
     if (
         long_running_function_call
         and initial_tool_response
@@ -131,6 +181,7 @@
       )
       print("--- Running agent's turn AFTER receiving updated tool result ---")
 
+      confirmation_call = None
       async for event in runner.run_async(
           session_id=session.id,
           user_id=USER_ID,
@@ -148,6 +199,8 @@
                   f" {part.function_call.name}({part.function_call.args}) ID:"
                   f" {part.function_call.id}"
               )
+              if part.function_call.name == "adk_request_confirmation":
+                confirmation_call = part.function_call
             if part.function_response:
               print(
                   f"    Part {i} [FunctionResponse]: For"
@@ -155,6 +208,12 @@
                   f" {part.function_response.id}, Response:"
                   f" {part.function_response.response}"
               )
+      if confirmation_call:
+        # The manager approved this ticket: let the held reimburse() run.
+        await answer_confirmation(
+            confirmation_call,
+            manager_approved=updated_tool_output_data["status"] == "approved",
+        )
       print("--- End of agent's turn AFTER receiving updated tool result ---")
 
     elif long_running_function_call and not initial_tool_response:
--- a/contributing/samples/hitl/human_in_loop/README.md
+++ b/contributing/samples/hitl/human_in_loop/README.md
@@ -47,4 +47,6 @@
 
 1. **Agent Acts on Update**: The agent receives this message containing the `types.FunctionResponse` and, based on its instructions, proceeds with the next steps (e.g., calling another tool like `reimburse`).
 
+1. **Application Confirms the Payout**: `reimburse` is wrapped in `FunctionTool(reimburse, require_confirmation=needs_manager_approval)`, so the framework holds every `reimburse` call of $100 or more and emits an `adk_request_confirmation` call instead of running it. Your application answers that call with a `ToolConfirmation` (`confirmed=True` only when the manager approved the ticket, see `answer_confirmation` in `main.py`), and only then does `reimburse` run. The instruction alone does not enforce the order of the two tools: without this gate the model can call `reimburse` before `ask_for_approval`, or after a rejection, and the payout goes through.
+
 **Why is this important?** The agent relies on receiving this subsequent `types.FunctionResponse` (provided in a message with `role="user"` containing the specific `Part`) to understand that the long-running task has concluded or its state has changed. Without it, the agent will remain unaware of the outcome of the pending task.
--- a/contributing/samples/hitl/tool_human_in_the_loop_config/tools.py
+++ b/contributing/samples/hitl/tool_human_in_the_loop_config/tools.py
@@ -17,8 +17,25 @@
 from google.adk.tools.tool_context import ToolContext
 
 
-def reimburse(purpose: str, amount: float) -> dict[str, str]:
+def reimburse(
+    purpose: str, amount: float, tool_context: ToolContext
+) -> dict[str, str]:
   """Reimburse the amount of money to the employee."""
+  # The instruction asks the model to call ask_for_approval() first, but
+  # nothing makes the model do so. Amounts that need a manager are held here
+  # with the framework's confirmation flow until the application confirms the
+  # call, which it does only for a ticket the manager approved. The check is
+  # inside the tool because `FunctionTool.from_config` does not map a
+  # `require_confirmation` arg from YAML (it logs "Unsupported parsing" and
+  # builds the tool without it).
+  if amount >= 100:
+    if not tool_context.tool_confirmation:
+      tool_context.request_confirmation(
+          hint=f'Confirm the reimbursement of ${amount} for {purpose}.',
+      )
+      return {'error': 'This reimbursement needs the manager\'s approval.'}
+    if not tool_context.tool_confirmation.confirmed:
+      return {'error': 'The reimbursement was rejected.'}
   return {
       'status': 'ok',
   }
poc.py (the reproduction script)
"""google/adk-python: the human_in_loop samples pay out without an approval.

Runs the repository's own samples (contributing/samples/hitl/human_in_loop/agent.py and the YAML variant
contributing/samples/hitl/tool_human_in_the_loop_config) through the repository's own Runner, with a scripted
model in place of Gemini (no API key, no network). The script plays the two sequences the sample's instruction
says must not happen, and the one it describes:

  S1  the model calls reimburse(amount=500) in its first turn, with no ask_for_approval at all;
  S2  the model calls ask_for_approval(500), the client answers {"status": "rejected"}, the model calls
      reimburse(500) anyway;
  S3  the model calls ask_for_approval(500), the client answers {"status": "approved"}, the model calls
      reimburse(500) (the flow the README describes).

The client side (this script) does what main.py does: it sends the manager's decision back as a FunctionResponse
with the long-running call's id. When the framework pauses a tool for confirmation (adk_request_confirmation,
which happens only on a patched copy), the client confirms it if and only if the manager approved that ticket.
A reimbursement counts as paid when a FunctionResponse for `reimburse` carries {"status": "ok"}, which the
sample's tool returns only when its body ran.

Usage: python 48_poc.py <repo root>    (the root must contain src/ and contributing/samples/hitl/)
"""
from __future__ import annotations

import asyncio
import logging
import sys
from pathlib import Path
from typing import Any, AsyncGenerator

REPO = Path(sys.argv[1]).resolve()
sys.path.insert(0, str(REPO / "src"))
sys.path.insert(0, str(REPO / "contributing" / "samples" / "hitl"))

import google.adk  # noqa: E402
from google.adk.agents.config_agent_utils import from_config  # noqa: E402
from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.llm_request import LlmRequest  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.adk.runners import Runner  # noqa: E402
from google.adk.sessions.in_memory_session_service import InMemorySessionService  # noqa: E402
from google.adk.tools.tool_confirmation import ToolConfirmation  # noqa: E402
from google.genai import types  # noqa: E402

AMOUNT = 500.0
logging.getLogger("google_adk").setLevel(logging.ERROR)  # hides the "missing token usage metadata" notices


class ScriptedModel(BaseLlm):
  """Pops one scripted reply per model call; answers with plain text once the script is exhausted."""

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

  @classmethod
  def supported_models(cls) -> list[str]:
    return ["scripted"]

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    self.calls += 1
    if self.script:
      yield LlmResponse(content=self.script.pop(0))
    else:
      yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="Done.")]))


def call(name: str, call_id: str, **args: Any) -> types.Content:
  return types.Content(
      role="model",
      parts=[types.Part(function_call=types.FunctionCall(name=name, args=args, id=call_id))],
  )


def describe(ev) -> str:
  bits = []
  for p in ev.content.parts if ev.content and ev.content.parts else []:
    if p.function_call:
      bits.append(f"call {p.function_call.name}({p.function_call.args})")
    if p.function_response:
      bits.append(f"resp {p.function_response.name} -> {p.function_response.response}")
    if p.text:
      bits.append(f"text {p.text!r}")
  lro = " [long-running]" if ev.long_running_tool_ids else ""
  return f"      [{ev.author}] " + "; ".join(bits) + lro


class Client:
  """The application side: drives the Runner and forwards the manager's decision, as main.py does."""

  def __init__(self, runner: Runner, session_id: str):
    self.runner, self.session_id = runner, session_id
    self.decision: str | None = None  # the manager's answer to ask_for_approval, once given
    self.paid = 0  # reimburse bodies that ran
    self.blocked = 0  # reimburse calls the framework held or rejected
    self.pending_call: types.FunctionCall | None = None  # the open ask_for_approval call

  async def turn(self, message: types.Content) -> None:
    confirm_call = None
    async for ev in self.runner.run_async(user_id="u", session_id=self.session_id, new_message=message):
      print(describe(ev))
      for fc in ev.get_function_calls():
        if fc.name == "adk_request_confirmation":
          confirm_call = fc
        if fc.id in (ev.long_running_tool_ids or []) and fc.name == "ask_for_approval":
          self.pending_call = fc
      for fr in ev.get_function_responses():
        if fr.name == "reimburse":
          if (fr.response or {}).get("status") == "ok":
            self.paid += 1
          elif "error" in (fr.response or {}):
            self.blocked += 1
    if confirm_call is not None:
      # The framework paused reimburse(). The client confirms only a ticket the manager approved.
      confirmed = self.decision == "approved"
      print(f"      <client> framework asks to confirm reimburse(); manager decision={self.decision!r},"
            f" answering confirmed={confirmed}")
      await self.turn(types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
          id=confirm_call.id, name=confirm_call.name,
          response=ToolConfirmation(confirmed=confirmed).model_dump()))]))

  async def manager_decides(self, status: str) -> types.Content:
    """Builds the updated FunctionResponse the README's 'Crucial Step' describes."""
    assert self.pending_call is not None, "no pending ask_for_approval call to answer"
    self.decision = status
    print(f"      <client> manager {status}: sending FunctionResponse for {self.pending_call.name}"
          f" id={self.pending_call.id}")
    return types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
        id=self.pending_call.id, name=self.pending_call.name,
        response={"status": status, "ticketId": "reimbursement-ticket-001"}))])


def load_agents() -> list[tuple[str, Any, ScriptedModel]]:
  from human_in_loop import agent as py_sample  # the Python sample, as shipped

  py_model = ScriptedModel()
  py_sample.root_agent.model = py_model
  yaml_agent = from_config(str(REPO / "contributing/samples/hitl/tool_human_in_the_loop_config/root_agent.yaml"))
  yaml_model = ScriptedModel()
  yaml_agent.model = yaml_model
  return [("hitl/human_in_loop (agent.py)", py_sample.root_agent, py_model),
          ("hitl/tool_human_in_the_loop_config (root_agent.yaml)", yaml_agent, yaml_model)]


async def scenario(label: str, agent: Any, model: ScriptedModel, name: str, decision: str | None) -> Client:
  runner = Runner(app_name="poc", agent=agent, session_service=InMemorySessionService())
  sid = f"{name}-{abs(hash(label)) % 10**6}"
  await runner.session_service.create_session(app_name="poc", user_id="u", session_id=sid)
  client = Client(runner, sid)
  ask = types.Content(role="user", parts=[types.Part(text=f"Please reimburse ${AMOUNT:.0f} for travel")])
  model.script.clear()
  if decision is None:
    print(f"   {name}: model calls reimburse({AMOUNT:.0f}) at once, never ask_for_approval")
    model.script.append(call("reimburse", "fc-reimburse-1", purpose="travel", amount=AMOUNT))
    await client.turn(ask)
  else:
    print(f"   {name}: model calls ask_for_approval({AMOUNT:.0f}); manager answers {decision!r};"
          f" model calls reimburse({AMOUNT:.0f})")
    model.script.append(call("ask_for_approval", "fc-ask-1", purpose="travel", amount=AMOUNT))
    await client.turn(ask)
    update = await client.manager_decides(decision)
    model.script.append(call("reimburse", "fc-reimburse-1", purpose="travel", amount=AMOUNT))
    await client.turn(update)
  print(f"   {name} result: reimburse ran {client.paid} time(s), held/rejected {client.blocked} time(s)")
  return client


async def main() -> None:
  print(f"google-adk {google.adk.__version__} from {google.adk.__file__}")
  summary = {}
  for label, agent, model in load_agents():
    print(f"\n=== {label}: tools = {[getattr(t, 'name', getattr(t, '__name__', t)) for t in agent.tools]}")
    results = {}
    for name, decision in (("S1 no approval", None), ("S2 rejected", "rejected"), ("S3 approved", "approved")):
      c = await scenario(label, agent, model, name, decision)
      results[name] = c.paid
    summary[label] = results
  print("\nSUMMARY (times the reimburse body ran per scenario):")
  for label, results in summary.items():
    print(f"   {label}: {results}")


if __name__ == "__main__":
  asyncio.run(main())

Activity

  1. surajksharma07 commented on Nov 4, 2026

    @surajksharma07
    Collaborator

    @trackoor I can reproduce this on current main (2.13.0): reimburse goes through with no approval and after a rejection, and require_confirmation set from YAML gets dropped with the “Unsupported parsing” warning.

    Your suggested approach looks right as a workaround: gate reimburse with FunctionTool(require_confirmation=...) in the Python sample and use tool_context.request_confirmation() inside the tool for the YAML one. With both, only the approved ticket pays out on my side.

    Could you check it once more against the README flow and the live-model run from main.py before opening the PR? Happy to review once it’s up. Keeping the A2A variant and the from_config YAML parsing as separate follow-ups probably makes sense.

  2. trackoor commented on Nov 4, 2026

    @trackoor
    Author

    Thanks for checking! Re-ran the README flow and main.py with live Gemini: the approved request pays once, and rejection pays nothing. Opened #7993; A2A and YAML parsing stay separate.

  3. surajksharma07 commented on Nov 5, 2026

    @surajksharma07
    Collaborator

    Went through #7993 @trackoor and the gate looks right. FunctionTool(require_confirmation=needs_manager_approval) on the Python sample, the in-tool request_confirmation() for YAML, and the instruction now says “at least $100” so it matches the >= 100 check. I also like that answer_confirmations only confirms one reimburse whose args match the approved ticket, so a changed amount or a second call gets refused.

    Checked against current main (still 2.13.0) and nothing upstream has touched these samples since, so the PR isn’t stale. CLA is green now and the CI workflows are waiting on maintainer approval, so I’ve flagged the PR for review.

    Keeping this issue open until #7993 lands. I’ll open separate issues for the from_config require_confirmation parsing and the A2A variant so they don’t get lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions