Skip to content

Let tools supply the hint and payload for the framework tool-confirmation gate (require_confirmation) #7737

Description

@hscholz

** Please make sure you read the contribution guide and file the issues in the right place. **
Contribution guide.

🔴 Required Information

Is your feature request related to a specific problem?

Since v2.10.0 (1d0daeb, 659e28f), the framework holds every tool whose check_require_confirmation returns True, before the tool's run_async runs (tools/_confirmation_utils.py::apply_confirmation_gate, called from flows/llm_flows/tools/_caller.py). The gate always asks with a fixed hint aimed at developers and with no payload:

Please approve or reject the tool call <name>() by responding with a FunctionResponse
with an expected ToolConfirmation payload.

A declaratively gated tool therefore can't tell the user what they are approving. We render tool confirmations as approval cards in a web frontend. For consent to mean anything, the card has to name what will happen, and the call's arguments alone often can't: ids need resolving to labels, and some values come from session state. Today the only place to supply that is tool_context.request_confirmation(hint=..., payload=...), which is the dynamic pattern.

Our frontend is built with A2UI (a2ui-agent-sdk) and AG-UI (ag-ui-adk). Results reach the user as A2UI surfaces, but we deliberately carry the approval card inside the ToolConfirmation payload rather than as a separate A2UI surface. A separate surface can be lost or reordered independently of the approval it describes, and routing the approval through UI events would open a second write path that bypasses the confirmation. So the confirmation payload is the only place the card can safely live, and the declarative gate doesn't let a tool fill it.

Up to v2.9.x, a FunctionTool subclass could do both: return True from check_require_confirmation, and override run_async to call request_confirmation(hint, payload) itself. Since v2.10.0 the framework gate runs first and the override never gets to raise its request, so the custom hint and the payload disappear. Nothing fails; the client just receives the generic text and payload=None. 659e28f notes that nothing FunctionTool gates changes, which is true for a plain FunctionTool but not for subclasses that raise their own request.

The dynamic pattern (no declarative flag; the tool calls request_confirmation itself) does carry a custom hint and payload, and on v2.12.0 it now resumes correctly for us, including when the gated agent is nested under a router or inside a sequential agent. On v2.10.0 that nested resume failed. It still has three drawbacks:

Describe the Solution You'd Like

A way for a tool to supply the hint and payload that the framework gate uses, so that one mechanism gives both central enforcement and a request a user can understand.

For example, a hook on BaseTool that apply_confirmation_gate calls instead of building the fixed hint:

class BaseTool:
    async def get_confirmation_request(
        self, args: dict[str, Any], tool_context: ToolContext
    ) -> ConfirmationRequest:
        """What the user is asked when this call is held for confirmation."""
        return ConfirmationRequest(hint=<current default hint>, payload=None)
# tools/_confirmation_utils.py
if confirmation is None:
    request = await tool.get_confirmation_request(function_args, tool_context)
    tool_context.request_confirmation(hint=request.hint, payload=request.payload)
    return {'error': 'This tool call requires confirmation, please approve or reject.'}

Plus a convenience argument on FunctionTool, mirroring the existing callable form of require_confirmation:

FunctionTool(
    create_ticket,
    require_confirmation=True,
    confirmation_request=lambda args, tool_context: ConfirmationRequest(
        hint=f'Create the ticket "{args["title"]}"?',
        payload={"title": args["title"], "priority": resolve_label(args["priority_id"])},
    ),
)

The same callable could be accepted where McpToolset / McpTool take require_confirmation. The default stays exactly what ships today, so nothing changes for existing users.

Optionally, the text returned to the model on rejection ({'error': 'This tool call is rejected.'}) could come from the same hook. Today a tool can't say anything about a rejection in its own terms without leaving the gate.

If ADK wants to go further, the payload could have an agreed shape for UIs, for example an A2UI component description, so that any A2UI renderer can display a confirmation without a per-application schema. Even without that, being able to set the payload at all is the essential part.

Impact on your work

We gate consequential writes (creating records in another system) behind human approval, and the approval card is the user's only view of what is being written. This doesn't block us: until a hook like this exists, we use the dynamic pattern behind one shared wrapper, so no tool hand-rolls the approve/reject check. But that gives up central enforcement of a rejection, needs our own marker so tests can find gated tools, and can't cover tools we don't write. We expect more gated tools, including MCP-based ones. No hard deadline.

Willingness to contribute

Yes


🟡 Recommended Information

Describe Alternatives You've Considered

  1. Override run_async on a FunctionTool subclass and keep check_require_confirmation → True. This worked up to v2.9.x. Since v2.10.0 it is pre-empted by the framework gate; measured on v2.10.0 and v2.12.0 (payload is None, generic hint).
  2. Return False from check_require_confirmation and raise the request in the tool (dynamic pattern). This gives the right card. On v2.10.0 the approval of a nested gated agent was silently dropped on resume ("Tool '' does not require confirmation"); on v2.12.0 it works in our tests. This is what we use for now, behind a shared wrapper, with the drawbacks above.
  3. Raise the request from a before_tool_callback (agent or plugin) and keep require_confirmation=True. Before-tool callbacks run before the gate in _execute_single_prepared_call. The callback raises the request on the first pass and returns None on the approved pass, and the gate then lets the confirmed call through. It works on v2.12.0, but it depends on callback-vs-gate ordering that isn't a documented contract, and it duplicates the gate's confirm/deny logic outside the tool.

Proposed API / Implementation

See "Describe the Solution You'd Like". The change sits in tools/_confirmation_utils.py::apply_confirmation_gate, plus the hook on BaseTool and the FunctionTool/MCP constructor arguments. The workflow _ToolNode, which calls the same gate since v2.11.0 (ce132b9), would pick it up automatically.

Additional Context

Activity

  1. sattyamj-attri commented on Oct 27, 2026

    @sattyamj-attri

    One thing that may matter for your setup: the Tool Confirmation docs still mark the feature experimental and, under Known limitations, list DatabaseSessionService (and VertexAiSessionService) as not supported:
    https://github.com/google/adk-docs/blob/cc53b6c145337f7fbc741b34602c1b65fe6ff3e4/docs/tools-custom/confirmation.md

    You mention resume works for you on 2.12.0 with Postgres, so maybe the docs are behind. It would help if a maintainer can confirm, since your whole approval flow depends on it.

    On ordering, it is at least spelt out in code comments in _caller.py: plugin before_tool_callback runs first (Step 1), then the canonical callback (Step 2), then the confirmation gate (Step 3). So a plugin-level callback also sees McpToolset tools. Still not a documented contract, which I think is your real point.

    # Step 1: Check if plugin before_tool_callback overrides the function
    # response.
    function_response = (
    await invocation_context.plugin_manager.run_before_tool_callback(
    tool=tool, tool_args=function_args, tool_context=tool_context
    )
    )
    # Step 2: If no overrides are provided from the plugins, further run the
    # canonical callback.
    if function_response is None:
    function_response = await _run_callbacks(
    agent.canonical_before_tool_callbacks, # type: ignore[arg-type]
    _stop_on_non_none,
    tool=tool,
    args=function_args,
    tool_context=tool_context,
    )
    if function_response is not None:
    response_source = 'before_tool_callback'
    # A tool name that resolved to nothing is answered once the before-tool
    # callbacks have had their chance to answer it themselves. The after-tool
    # callbacks are skipped: they describe a tool run that did not happen.
    if (
    function_response is None
    and prepared_call.tool_lookup_error is not None
    ):
    detected_error_type = type(prepared_call.tool_lookup_error).__name__
    function_response = await _tool_error_handler.run_on_tool_error_callbacks(
    invocation_context=invocation_context,
    agent=agent,
    tool=tool,
    tool_args=function_args,
    tool_context=tool_context,
    error=prepared_call.tool_lookup_error,
    )
    if function_response is None:
    logger.warning('%s', prepared_call.tool_lookup_error)
    function_response = _tool_error_handler.build_tool_not_found_response(
    tool.name, prepared_call.tools_dict
    )
    else:
    response_source = 'on_tool_error_callback'
    return _build_response_event(
    tool, function_response, tool_context, invocation_context
    )
    # Step 3: No before-tool callback answered the call, so proceed calling
    # the tool normally. A tool that requires confirmation is answered by the
    # gate instead, so the gate holds for every tool rather than only the ones
    # that check it themselves, and a gate that raises is handled like a tool
    # that raises. A call that is cancelled -- by a failed parallel call, a
    # node timeout or the caller -- reaches the error callbacks too, so what
    # a before-tool callback set up can be released, and stays cancelled.
    if function_response is None:
    try:
    with _use_sync_callable_context(tool_context):
    function_response = await apply_confirmation_gate(

    Also +1 on a separate hook for the hint and payload. In _confirmation_utils.py, the gate does if requires_confirmation is not True: return None, so if the require_confirmation callable started returning a request object, calls would go through unconfirmed.

    One untested idea: on approval, the gate checks only confirmed (L70-72), not that the executed args match what was shown. If the card is built from a tool-supplied payload, a test that the payload and executed args match would be worth adding.

  2. sanketpatil06 commented on Oct 27, 2026

    @sanketpatil06

    Hi @hscholz, thanks for request. If you have a solution in mind, please feel free to raise a PR. Our team will be happy to review it.

  3. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Oct 27, 2026
  4. inamdarmihir commented on Oct 29, 2026

    @inamdarmihir
    Contributor

    Opened #7798 for this, following the hook sketched in the issue: BaseTool.get_confirmation_request(args, tool_context) returning a new ConfirmationRequest(hint, payload). The default reproduces today's hint with payload=None. The framework gate and the run_async fallbacks of FunctionTool and McpTool use it, and the call is still held until approved whatever the hook returns. If the hook raises, the call neither runs nor is recorded as awaiting confirmation. I left out a FunctionTool(...) constructor shortcut and a rejection-text hook to keep the API small. Open to renaming the hook.

  5. salahmakboul commented on Oct 31, 2026

    @salahmakboul

    A small thought for #7798, now that the card can carry a payload. Once a card shows resolved labels and session values, what the person read and what the tool runs can drift apart: the label resolves one way at display time, and the arguments change before the call.

    One way to catch that is to hash the card text and the arguments together when the card is shown, return the hash with the answer, and refuse on a mismatch. I checked the logic in a few lines of plain Python, not in ADK:

    change after the card was shown hash matches?
    none yes
    arguments changed no, refused
    card text changed no, refused

    It would also need ConfirmationRequest to keep what was shown, so the check has something to compare.

    This comment was written with AI assistance. I maintain a related library, but this needs nothing from it.

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

Metadata

Metadata

Assignees

Labels

needs review[Status] The PR/issue is awaiting review from the maintainertools[Component] This issue is related to tools

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions