Tool confirmation (HITL)

Gate dangerous tool calls behind explicit human approval with the adk_request_confirmation pause-and-resume flow.

A tool marked require_confirmation never runs on the model's say-so alone. Instead of dispatching, the agent emits a synthetic adk_request_confirmation function response and pauses the invocation; your application shows the hint to a human, and resubmits their decision as a function response. Only an explicit approval lets the original call execute — exactly once.

The flow, step by step

  1. Register the gate. Build the tool with FunctionTool::require_confirmation(true) (optionally with_confirmation_hint), or implement DynTool::requires_confirmation yourself — it receives the call args, so you can confirm only destructive parameter combinations.
  2. The model calls the tool. The agent's dispatch pipeline (confirmation → auth → run) sees the gate and does not run the tool. It emits a FunctionResponse named adk_request_confirmation (REQUEST_CONFIRMATION_FUNCTION_NAME) with the same function-call id, will_continue: Some(true), and a serialized ConfirmationRequest as the response value.
  3. The invocation pauses. The tool-response event lists the call id in long_running_tool_ids and in actions.requested_tool_confirmations (id → ToolConfirmation), and the agent stops issuing turns.
  4. Show the hint, collect a decision. Your UI presents ToolConfirmation.hint (and the originalFunctionCall args) to the human.
  5. Resubmit the decision. Send a new user Content containing a FunctionResponse with the same id, name adk_request_confirmation, and a ToolConfirmation value (confirmed: true/false, optional payload).
  6. The runner absorbs and replays. ConfirmationPreprocessor::process_event extracts decisions from the user event into a ConfirmationOutcome; the owning agent replays the original call — running the tool if confirmed (the decision is injected into ToolContext::tool_confirmation), or returning {"error": "tool call was rejected by the user"} to the model if denied.

Types

pub const REQUEST_CONFIRMATION_FUNCTION_NAME: &str = "adk_request_confirmation"
Name of the synthetic function response used to request — and answer — a confirmation.
struct ToolConfirmation { hint: String, confirmed: bool, payload: Option<Value> }
One decision (or request). payload carries optional structured data the user attached, e.g. an amended parameter set; it reaches the tool via ToolContext::tool_confirmation.
struct ConfirmationRequest { original_function_call: FunctionCall, tool_confirmation: ToolConfirmation }
Payload of the pending response. Serialized in camelCase (originalFunctionCall, toolConfirmation) for adk-web compatibility.
ConfirmationPreprocessor::process_event(&self, event: &Event) -> ConfirmationOutcome
Runner-side absorber: walks a user event's function responses for adk_request_confirmation entries and decodes the decisions into ConfirmationOutcome { responses: IndexMap<String, ToolConfirmation> }.
FunctionTool::require_confirmation(self, yes: bool) -> Self
Gate every call to this tool behind confirmation.
FunctionTool::with_confirmation_hint(self, hint: impl Into<String>) -> Self
Custom hint. Default: `Approve execution of tool {name}?`
DynTool::requires_confirmation(&self, args: &Value) -> bool
Trait-level hook (default false). args lets implementations decide per call.

Wire shape

The pending request the agent emits is a FunctionResponse part whose response value is the ConfirmationRequest:

response value of the adk_request_confirmation FunctionResponsejson
{
  "originalFunctionCall": {
    "id": "call-1",
    "name": "transfer_money",
    "args": { "amount": 100 }
  },
  "toolConfirmation": {
    "hint": "Approve this transfer?",
    "confirmed": false
  }
}

The answer is a user-authored FunctionResponse with the same id. The preprocessor accepts a bare ToolConfirmation, or one wrapped as {"toolConfirmation": {...}} or {"response": {...}}. Responses without a function-call id are dropped with a warning.

End-to-end example

Gate, pause, approverust
use adk_rs::core::{RunConfig, ToolContext, REQUEST_CONFIRMATION_FUNCTION_NAME};
use adk_rs::genai_types::{Content, FunctionResponse, Part, Role, Schema};
use adk_rs::tools::FunctionTool;
use serde_json::Value;
use std::sync::Arc;

let transfer = FunctionTool::from_async(
    "transfer_money",
    "Transfer money between accounts",
    Some(Schema::object().property("amount", Schema::number()).require("amount")),
    |args: Value, _ctx: &mut ToolContext| async move {
        Ok(serde_json::json!({ "ok": true, "transferred": args["amount"] }))
    },
)
.require_confirmation(true)
.with_confirmation_hint("Approve this transfer?");

// Turn 1: the model calls transfer_money; the run pauses instead.
let mut events = runner.run("user", Some(&session_id), "send $100").await?;
while let Some(event) = events.next().await {
    let event = event?;
    for (call_id, confirmation) in &event.actions.requested_tool_confirmations {
        println!("approval needed for {call_id}: {}", confirmation.hint);
    }
}

// Turn 2: resubmit the human's decision with the SAME call id.
let approval = Content {
    role: Role::User,
    parts: vec![Part::FunctionResponse(FunctionResponse {
        id: Some("call-1".into()),
        name: REQUEST_CONFIRMATION_FUNCTION_NAME.into(),
        response: serde_json::json!({ "confirmed": true }),
        will_continue: None,
        scheduling: None,
    })],
};
let events = runner
    .run_with("user", Some(&session_id), approval, RunConfig::default())
    .await?;
// transfer_money now runs exactly once; a denial would instead surface
// {"error": "tool call was rejected by the user"} to the model.

MCP tools

MCP servers expose tools you did not write, so the gate lives on the toolset: McpToolset::with_confirmation_policy takes a ConfirmationPolicyNone (default), All, or Named(HashSet<String>) for a subset of discovered tools. Set it before the first list_tools call, since discovered tools are cached. See MCP.