Repository navigation
Description
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 norequire_confirmation, nobefore_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) returnspendingand never records anything, and the later "approved" message is a client-authoredFunctionResponse(main.py lines 112-126) that no code ties to thereimbursecall that follows: the model may callreimbursewithout it, with arejectedone, 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 agentcontributing/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:
- Clone the repository at 36cb10d and install it (
uv pip install -e .or the pinned requirements). - Save the attached
poc.py(it imports the samples fromcontributing/samples/hitland the framework fromsrc, and replaces the model with a scriptedBaseLlm). - Run
python poc.py <repo root>. - Read the two SUMMARY lines: the
reimbursebody 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
BaseLlmsubclass (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)withneeds_manager_approval(purpose, amount) = amount >= 100, the threshold the instruction already states (the same shape ashitl/human_tool_confirmation/agent.pylines 90-93).hitl/human_in_loop/main.py: answers theadk_request_confirmationpause withToolConfirmation(confirmed=...),Trueonly after the simulated manager approved the ticket,Falsefor areimbursethe 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 insidereimbursewithtool_context.request_confirmation()(the idiom ofhitl/tool_confirmation/agent.pylines 27-38), because the YAML route cannot express it through constructor args:FunctionTool.from_config(src/google/adk/tools/base_tool.pylines 212-291) leavesrequire_confirmationout of the kwargs for aUnion[bool, Callable]parameter and only logs. Executed on 36cb10d with a YAML toolname: 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_confirmationpause 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?:
- Always (100%) with the scripted model; with a live model whenever it chooses to call
reimbursefirst, which How to achieve proper human in the loop approval with long running function calls? #1851 shows a single user message can bring about.
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
surajksharma07 commentedon Nov 4, 2026
@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.
trackoor commentedon Nov 4, 2026
Thanks for checking! Re-ran the README flow and
main.pywith live Gemini: the approved request pays once, and rejection pays nothing. Opened #7993; A2A and YAML parsing stay separate.surajksharma07 commentedon Nov 5, 2026
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.