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
- Register the gate. Build the tool with
FunctionTool::require_confirmation(true)(optionallywith_confirmation_hint), or implementDynTool::requires_confirmationyourself — it receives the callargs, so you can confirm only destructive parameter combinations. - 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
FunctionResponsenamedadk_request_confirmation(REQUEST_CONFIRMATION_FUNCTION_NAME) with the same function-callid,will_continue: Some(true), and a serializedConfirmationRequestas the response value. - The invocation pauses. The tool-response event lists the call id in
long_running_tool_idsand inactions.requested_tool_confirmations(id →ToolConfirmation), and the agent stops issuing turns. - Show the hint, collect a decision. Your UI presents
ToolConfirmation.hint(and theoriginalFunctionCallargs) to the human. - Resubmit the decision. Send a new user
Contentcontaining aFunctionResponsewith the sameid, nameadk_request_confirmation, and aToolConfirmationvalue (confirmed: true/false, optionalpayload). - The runner absorbs and replays.
ConfirmationPreprocessor::process_eventextracts decisions from the user event into aConfirmationOutcome; the owning agent replays the original call — running the tool if confirmed (the decision is injected intoToolContext::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).
payloadcarries optional structured data the user attached, e.g. an amended parameter set; it reaches the tool viaToolContext::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_confirmationentries and decodes the decisions intoConfirmationOutcome { 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).argslets implementations decide per call.
Wire shape
The pending request the agent emits is a FunctionResponse part whose response value is the ConfirmationRequest:
{
"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
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 ConfirmationPolicy — None (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.
- Cancellation & resume — what pauses an invocation and how
resumeworks. - Function tools — the rest of the
FunctionToolsurface. - MCP — confirmation policies for discovered toolsets.