Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions contributing/samples/a2a/a2a_human_in_loop/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,56 @@
# limitations under the License.


from typing import Any

from google.adk.agents.llm_agent import Agent
from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.apps import App
from google.adk.apps import ResumabilityConfig
from google.adk.tools.tool_context import ToolContext
from google.genai import types

from .approval_config import get_approval_threshold_usd
from .approval_config import requires_manager_approval


def reimburse(
purpose: str, amount: float, tool_context: ToolContext
) -> dict[str, Any]:
"""Reimburse the amount of money to the employee.

def reimburse(purpose: str, amount: float) -> str:
"""Reimburse the amount of money to the employee."""
Whether this amount needs manager confirmation is decided by a server-side,
config-derived threshold (see approval_config.py) -- not by this function's
arguments, and not only by the agent's instruction text. `amount` at or
above the threshold cannot be reimbursed by a direct call to this tool: the
call is parked pending confirmation, and only executes once
`tool_context.tool_confirmation.confirmed` is True. This closes the gap
where an instruction telling the model to delegate large amounts to
`approval_agent` was the *only* thing standing between a large amount and
this tool actually running.
"""
if requires_manager_approval(amount):
if not tool_context.tool_confirmation:
tool_context.request_confirmation(
hint=(
f'Reimbursement of ${amount} for {purpose!r} is at or above'
f' the ${get_approval_threshold_usd():.2f} auto-approval'
' threshold and requires manager confirmation.'
),
)
return {
'status': 'pending_confirmation',
'error': (
'This reimbursement requires manager confirmation before it'
' can be processed.'
),
}
if not tool_context.tool_confirmation.confirmed:
return {
'status': 'rejected',
'error': 'Reimbursement was not confirmed.',
}
return {
'status': 'ok',
}
Expand Down
64 changes: 64 additions & 0 deletions contributing/samples/a2a/a2a_human_in_loop/approval_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Server-side, config-derived approval threshold for this sample.

The threshold that decides whether a reimbursement needs manager confirmation
must be resolved from server-side configuration only, and must never be
something a tool caller -- the model, or anyone crafting tool-call arguments --
can pass in and change. This is the general shape of a real, previously-seen
production bug: a caller-controlled value (a threshold, a confidence score, a
spend limit) silently overrode the value an approval gate was supposed to
enforce, defeating the gate. The fix is not to trust a prompt instruction to
keep the model from calling the tool for large amounts (that is what this
sample did before this change) -- it is to make the tool itself refuse,
in code, using a value the caller has no argument to influence.

Nothing in this module accepts a threshold from a caller: the only input is
an environment variable read at call time, set by whoever deploys the agent,
never by a tool-call argument.
"""

from __future__ import annotations

import os

# Name of the environment variable a deployer can set to change the
# threshold. This is intentionally NOT a parameter of `reimburse()` or any
# other tool function in this sample.
APPROVAL_THRESHOLD_ENV_VAR = 'REIMBURSEMENT_APPROVAL_THRESHOLD_USD'
DEFAULT_APPROVAL_THRESHOLD_USD = 100.0


def get_approval_threshold_usd() -> float:
"""Returns the current auto-approval threshold, in USD.

Reads `REIMBURSEMENT_APPROVAL_THRESHOLD_USD` from the environment if set
(server-side config), otherwise falls back to
`DEFAULT_APPROVAL_THRESHOLD_USD`. There is deliberately no code path here
that lets a tool-call argument, or any caller-supplied dict, influence this
value.
"""
raw = os.environ.get(APPROVAL_THRESHOLD_ENV_VAR)
if raw is None:
return DEFAULT_APPROVAL_THRESHOLD_USD
try:
return float(raw)
except ValueError:
return DEFAULT_APPROVAL_THRESHOLD_USD


def requires_manager_approval(amount: float) -> bool:
"""Whether `amount` requires manager confirmation before it is reimbursed."""
return amount >= get_approval_threshold_usd()
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,43 @@
from google.adk.tools.tool_context import ToolContext
from google.genai import types

from .approval_config import get_approval_threshold_usd
from .approval_config import requires_manager_approval

def reimburse(purpose: str, amount: float) -> str:
"""Reimburse the amount of money to the employee."""

def reimburse(
purpose: str, amount: float, tool_context: ToolContext
) -> dict[str, Any]:
"""Reimburse the amount of money to the employee.

Whether this amount needs manager confirmation is decided by a server-side,
config-derived threshold (see approval_config.py) -- not by this function's
arguments, and not only by the agent's instruction text. `amount` at or
above the threshold cannot be reimbursed by a direct call to this tool: the
call is parked pending confirmation, and only executes once
`tool_context.tool_confirmation.confirmed` is True.
"""
if requires_manager_approval(amount):
if not tool_context.tool_confirmation:
tool_context.request_confirmation(
hint=(
f'Reimbursement of ${amount} for {purpose!r} is at or above'
f' the ${get_approval_threshold_usd():.2f} auto-approval'
' threshold and requires manager confirmation.'
),
)
return {
'status': 'pending_confirmation',
'error': (
'This reimbursement requires manager confirmation before it'
' can be processed.'
),
}
if not tool_context.tool_confirmation.confirmed:
return {
'status': 'rejected',
'error': 'Reimbursement was not confirmed.',
}
return {
'status': 'ok',
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Server-side, config-derived approval threshold for this sample.

The threshold that decides whether a reimbursement needs manager confirmation
must be resolved from server-side configuration only, and must never be
something a tool caller -- the model, or anyone crafting tool-call arguments --
can pass in and change. This is the general shape of a real, previously-seen
production bug: a caller-controlled value (a threshold, a confidence score, a
spend limit) silently overrode the value an approval gate was supposed to
enforce, defeating the gate. The fix is not to trust a prompt instruction to
keep the model from calling the tool for large amounts (that is what this
sample did before this change) -- it is to make the tool itself refuse,
in code, using a value the caller has no argument to influence.

Nothing in this module accepts a threshold from a caller: the only input is
an environment variable read at call time, set by whoever deploys the agent,
never by a tool-call argument.
"""

from __future__ import annotations

import os

# Name of the environment variable a deployer can set to change the
# threshold. This is intentionally NOT a parameter of `reimburse()` or any
# other tool function in this sample.
APPROVAL_THRESHOLD_ENV_VAR = 'REIMBURSEMENT_APPROVAL_THRESHOLD_USD'
DEFAULT_APPROVAL_THRESHOLD_USD = 100.0


def get_approval_threshold_usd() -> float:
"""Returns the current auto-approval threshold, in USD.

Reads `REIMBURSEMENT_APPROVAL_THRESHOLD_USD` from the environment if set
(server-side config), otherwise falls back to
`DEFAULT_APPROVAL_THRESHOLD_USD`. There is deliberately no code path here
that lets a tool-call argument, or any caller-supplied dict, influence this
value.
"""
raw = os.environ.get(APPROVAL_THRESHOLD_ENV_VAR)
if raw is None:
return DEFAULT_APPROVAL_THRESHOLD_USD
try:
return float(raw)
except ValueError:
return DEFAULT_APPROVAL_THRESHOLD_USD


def requires_manager_approval(amount: float) -> bool:
"""Whether `amount` requires manager confirmation before it is reimbursed."""
return amount >= get_approval_threshold_usd()
Loading