Skip to content

Commit 982f608

Browse files
trackoorcopybara-github
authored andcommitted
fix: require confirmation before large reimbursements in hitl samples
Merge #7993 In the human_in_loop and tool_human_in_the_loop_config samples, reimburse calls of $100 or more now pause for a tool confirmation instead of running whenever the model calls them. The YAML sample's reimburse tool now takes a tool_context. The A2A human-in-the-loop sample is unchanged. Part of #7987 PiperOrigin-RevId: 1015684580
1 parent 946ca77 commit 982f608

7 files changed

Lines changed: 377 additions & 10 deletions

File tree

‎contributing/samples/hitl/human_in_loop/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ This example demonstrates an agent using a long-running tool (`ask_for_approval`
4747

4848
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`).
4949

50+
1. **Application Confirms the Reimbursement**: For amounts of $100 or more, `FunctionTool(require_confirmation=needs_manager_approval)` holds `reimburse` and emits `adk_request_confirmation`. The application answers with a `FunctionResponse` using that confirmation call's `id` and `name`, with a `ToolConfirmation` payload. In `main.py`, `answer_confirmations` confirms at most one call matching the approved ticket's amount and purpose (purpose compared ignoring case and surrounding whitespace); calls without approval are rejected. The instruction and the updated approval response alone do not authorize a reimbursement. The YAML sibling, `tool_human_in_the_loop_config`, gates the same call with `tool_context.request_confirmation()` inside `reimburse` instead, because `AgentConfig` YAML cannot express `require_confirmation` yet.
51+
5052
**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.

‎contributing/samples/hitl/human_in_loop/agent.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any
1616

1717
from google.adk import Agent
18+
from google.adk.tools.function_tool import FunctionTool
1819
from google.adk.tools.long_running_tool import LongRunningFunctionTool
1920
from google.adk.tools.tool_context import ToolContext
2021
from google.genai import types
@@ -38,18 +39,26 @@ def ask_for_approval(
3839
}
3940

4041

42+
def needs_manager_approval(purpose: str, amount: float) -> bool:
43+
"""Require manager approval for reimbursements of $100 or more."""
44+
return amount >= 100
45+
46+
4147
root_agent = Agent(
4248
name='reimbursement_agent',
4349
instruction="""
4450
You are an agent whose job is to handle the reimbursement process for
4551
the employees. If the amount is less than $100, you will automatically
4652
approve the reimbursement.
4753
48-
If the amount is greater than $100, you will
54+
If the amount is at least $100, you will
4955
ask for approval from the manager. If the manager approves, you will
5056
call reimburse() to reimburse the amount to the employee. If the manager
5157
rejects, you will inform the employee of the rejection.
5258
""",
53-
tools=[reimburse, LongRunningFunctionTool(func=ask_for_approval)],
59+
tools=[
60+
FunctionTool(reimburse, require_confirmation=needs_manager_approval),
61+
LongRunningFunctionTool(func=ask_for_approval),
62+
],
5463
generate_content_config=types.GenerateContentConfig(temperature=0.1),
5564
)

‎contributing/samples/hitl/human_in_loop/main.py‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@
1818

1919
import agent
2020
from dotenv import load_dotenv
21-
from google.adk.agents.llm_agent import Agent
22-
from google.adk.events.event import Event
2321
from google.adk.runners import Runner
2422
from google.adk.sessions.in_memory_session_service import InMemorySessionService
25-
from google.adk.tools.long_running_tool import LongRunningFunctionTool
23+
from google.adk.tools.tool_confirmation import ToolConfirmation
2624
from google.genai import types
2725
from opentelemetry import trace
2826
from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
@@ -48,6 +46,57 @@ async def main():
4846
session_service=session_service,
4947
)
5048

49+
async def answer_confirmations(
50+
confirmation_calls: list[types.FunctionCall],
51+
*,
52+
approved_args: dict[str, Any] | None = None,
53+
) -> None:
54+
"""Confirm at most one reimbursement matching the approved ticket."""
55+
for confirmation_call in confirmation_calls:
56+
original_call = (confirmation_call.args or {}).get(
57+
"originalFunctionCall", {}
58+
)
59+
call_args = original_call.get("args") or {}
60+
# Purpose is compared loosely so model wording drift does not reject it.
61+
confirmed = (
62+
approved_args is not None
63+
and original_call.get("name") == "reimburse"
64+
and call_args.get("amount") == approved_args.get("amount")
65+
and str(call_args.get("purpose", "")).strip().lower()
66+
== str(approved_args.get("purpose", "")).strip().lower()
67+
)
68+
if confirmed:
69+
approved_args = None
70+
print(f"--- Confirming reimbursement: {confirmed} ---")
71+
async for event in runner.run_async(
72+
session_id=session.id,
73+
user_id=USER_ID,
74+
new_message=types.Content(
75+
role="user",
76+
parts=[
77+
types.Part(
78+
function_response=types.FunctionResponse(
79+
id=confirmation_call.id,
80+
name=confirmation_call.name,
81+
response=ToolConfirmation(
82+
confirmed=confirmed
83+
).model_dump(),
84+
)
85+
)
86+
],
87+
),
88+
):
89+
if event.content and event.content.parts:
90+
for i, part in enumerate(event.content.parts):
91+
if part.text:
92+
print(f" Part {i} [Text]: {part.text.strip()}")
93+
if part.function_response:
94+
print(
95+
f" Part {i} [FunctionResponse]: For"
96+
f" '{part.function_response.name}', Response:"
97+
f" {part.function_response.response}"
98+
)
99+
51100
async def call_agent(query: str):
52101
content = types.Content(role="user", parts=[types.Part(text=query)])
53102

@@ -61,6 +110,7 @@ async def call_agent(query: str):
61110
long_running_function_call: types.FunctionCall | None = None
62111
initial_tool_response: types.FunctionResponse | None = None
63112
ticket_id: str | None = None
113+
confirmation_calls: list[types.FunctionCall] = []
64114

65115
async for event in events_async:
66116
if event.content and event.content.parts:
@@ -73,7 +123,9 @@ async def call_agent(query: str):
73123
f" {part.function_call.name}({part.function_call.args}) ID:"
74124
f" {part.function_call.id}"
75125
)
76-
if not long_running_function_call and part.function_call.id in (
126+
if part.function_call.name == "adk_request_confirmation":
127+
confirmation_calls.append(part.function_call)
128+
elif not long_running_function_call and part.function_call.id in (
77129
event.long_running_tool_ids or []
78130
):
79131
long_running_function_call = part.function_call
@@ -102,6 +154,9 @@ async def call_agent(query: str):
102154

103155
print("--- End of agent's initial turn ---\n")
104156

157+
# No ticket has been approved yet, even if the model calls reimburse first.
158+
await answer_confirmations(confirmation_calls)
159+
105160
if (
106161
long_running_function_call
107162
and initial_tool_response
@@ -131,6 +186,7 @@ async def call_agent(query: str):
131186
)
132187
print("--- Running agent's turn AFTER receiving updated tool result ---")
133188

189+
confirmation_calls = []
134190
async for event in runner.run_async(
135191
session_id=session.id,
136192
user_id=USER_ID,
@@ -143,6 +199,8 @@ async def call_agent(query: str):
143199
if part.text:
144200
print(f" Part {i} [Text]: {part.text.strip()}")
145201
if part.function_call:
202+
if part.function_call.name == "adk_request_confirmation":
203+
confirmation_calls.append(part.function_call)
146204
print(
147205
f" Part {i} [FunctionCall]:"
148206
f" {part.function_call.name}({part.function_call.args}) ID:"
@@ -155,6 +213,14 @@ async def call_agent(query: str):
155213
f" {part.function_response.id}, Response:"
156214
f" {part.function_response.response}"
157215
)
216+
await answer_confirmations(
217+
confirmation_calls,
218+
approved_args=(
219+
long_running_function_call.args
220+
if updated_tool_output_data["status"] == "approved"
221+
else None
222+
),
223+
)
158224
print("--- End of agent's turn AFTER receiving updated tool result ---")
159225

160226
elif long_running_function_call and not initial_tool_response:

‎contributing/samples/hitl/human_in_loop/tests/reimburse_dinner.json‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,95 @@
131131
"path": "reimbursement_agent@1"
132132
}
133133
},
134+
{
135+
"author": "reimbursement_agent",
136+
"content": {
137+
"parts": [
138+
{
139+
"functionCall": {
140+
"args": {
141+
"originalFunctionCall": {
142+
"args": {
143+
"amount": 150,
144+
"purpose": "client dinner"
145+
},
146+
"id": "fc-2",
147+
"name": "reimburse"
148+
},
149+
"toolConfirmation": {
150+
"confirmed": false,
151+
"hint": "Please approve or reject the tool call reimburse() by responding with a FunctionResponse with an expected ToolConfirmation payload."
152+
}
153+
},
154+
"id": "fc-3",
155+
"name": "adk_request_confirmation"
156+
}
157+
}
158+
],
159+
"role": "model"
160+
},
161+
"id": "e-7",
162+
"invocationId": "i-2",
163+
"longRunningToolIds": [
164+
"fc-3"
165+
],
166+
"nodeInfo": {
167+
"path": "reimbursement_agent@1"
168+
}
169+
},
170+
{
171+
"actions": {
172+
"requestedToolConfirmations": {
173+
"fc-2": {
174+
"confirmed": false,
175+
"hint": "Please approve or reject the tool call reimburse() by responding with a FunctionResponse with an expected ToolConfirmation payload."
176+
}
177+
},
178+
"skipSummarization": true
179+
},
180+
"author": "reimbursement_agent",
181+
"content": {
182+
"parts": [
183+
{
184+
"functionResponse": {
185+
"id": "fc-2",
186+
"name": "reimburse",
187+
"response": {
188+
"error": "This tool call requires confirmation, please approve or reject."
189+
}
190+
}
191+
}
192+
],
193+
"role": "user"
194+
},
195+
"id": "e-8",
196+
"invocationId": "i-2",
197+
"nodeInfo": {
198+
"path": "reimbursement_agent@1"
199+
}
200+
},
201+
{
202+
"author": "user",
203+
"content": {
204+
"parts": [
205+
{
206+
"functionResponse": {
207+
"id": "fc-3",
208+
"name": "adk_request_confirmation",
209+
"response": {
210+
"confirmed": true
211+
}
212+
}
213+
}
214+
],
215+
"role": "user"
216+
},
217+
"id": "e-9",
218+
"invocationId": "i-2",
219+
"nodeInfo": {
220+
"path": ""
221+
}
222+
},
134223
{
135224
"author": "reimbursement_agent",
136225
"content": {
@@ -147,7 +236,7 @@
147236
],
148237
"role": "user"
149238
},
150-
"id": "e-7",
239+
"id": "e-10",
151240
"invocationId": "i-2",
152241
"nodeInfo": {
153242
"path": "reimbursement_agent@1"
@@ -164,7 +253,7 @@
164253
"role": "model"
165254
},
166255
"finishReason": "STOP",
167-
"id": "e-8",
256+
"id": "e-11",
168257
"invocationId": "i-2",
169258
"nodeInfo": {
170259
"path": "reimbursement_agent@1"

‎contributing/samples/hitl/tool_human_in_the_loop_config/root_agent.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ instruction: |
2020
the employees. If the amount is less than $100, you will automatically
2121
approve the reimbursement.
2222
23-
If the amount is greater than $100, you will
23+
If the amount is at least $100, you will
2424
ask for approval from the manager. If the manager approves, you will
2525
call reimburse() to reimburse the amount to the employee. If the manager
2626
rejects, you will inform the employee of the rejection.

‎contributing/samples/hitl/tool_human_in_the_loop_config/tools.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,19 @@
1717
from google.adk.tools.tool_context import ToolContext
1818

1919

20-
def reimburse(purpose: str, amount: float) -> dict[str, str]:
20+
def reimburse(
21+
purpose: str, amount: float, tool_context: ToolContext
22+
) -> dict[str, str]:
2123
"""Reimburse the amount of money to the employee."""
24+
if amount >= 100:
25+
if not tool_context.tool_confirmation:
26+
tool_context.request_confirmation(
27+
hint=f'Confirm the reimbursement of ${amount} for {purpose}.',
28+
)
29+
tool_context.actions.skip_summarization = True
30+
return {'error': 'This reimbursement needs manager approval.'}
31+
if not tool_context.tool_confirmation.confirmed:
32+
return {'error': 'The reimbursement was rejected.'}
2233
return {
2334
'status': 'ok',
2435
}

0 commit comments

Comments
 (0)