ツール認可
エージェントが実行できるツールと、人間の承認が必要になるタイミングを制御します。ADK-Rust は、単純なツールごとの確認から完全な RBAC まで、4 つのメカニズムを提供し、CLI、ウェブサーバー、および A2A プロトコル全体で機能します。
クイック比較
| 仕組み | 使用例 | 粒度 | 実行時の動作 |
|---|---|---|---|
| ツール確認ポリシー | CLI/ウェブでの対話的な承認 | ツール単位またはすべてのツール | 実行を一時停止し、イベントを発行 |
| BeforeToolCallback | プログラムによるゲート / 監査 | 呼び出しごとのカスタムロジック | 同期的に判断し、停止なし |
| アクセス制御 (RBAC) | ロールベースのエンタープライズセキュリティ | ユーザーごと、ツールごと | 実行前に拒否 |
| グラフ割り込み | 複雑な承認ワークフロー | ノードごとのチェックポイント | 状態を保持し、後で再開 |
ツール確認ポリシー
組み込みのヒューマン・イン・ザ・ループ機構です。確認を必要とするツールが呼び出されると、エージェントは一時停止し、ToolConfirmationRequestイベントを発行して、次の実行でApproveまたはDenyの判断を待機します。
セットアップ
use adk_agent::LlmAgentBuilder;
use std::sync::Arc;
let agent = LlmAgentBuilder::new("assistant")
.model(model)
.instruction("You are a helpful assistant with file and email tools.")
.tool(Arc::new(search_tool))
.tool(Arc::new(delete_file_tool))
.tool(Arc::new(send_email_tool))
// Require confirmation for dangerous tools
.require_tool_confirmation("delete_file")
.require_tool_confirmation("send_email")
.build()?;
// Or require confirmation for ALL tool calls:
// .require_tool_confirmation_for_all()
仕組み
- LLMが
delete_fileを引数{"path": "/data/report.csv"}で呼び出すことを決定します - エージェントは次の内容を含む
Eventを発行します。{ "actions": { "toolConfirmation": { "toolName": "delete_file", "functionCallId": "call_abc123", "args": {"path": "/data/report.csv"} } } } - エージェントのストリームが終了し、実行が一時停止します
- UIに次の内容を表示します。"エージェントが
/data/report.csvを削除しようとしています。許可しますか?" - 次の
Runner::run()で、リクエストの関数呼び出しIDをキーにして判断を渡します。
use adk_core::{RunConfig, ToolConfirmationDecision};
use std::collections::HashMap;
let mut decisions = HashMap::new();
decisions.insert(
"call_abc123".to_string(), // functionCallId from the request, not the tool name
ToolConfirmationDecision::Approve, // or Deny
);
// The runner picks up the decision and continues
拒否された場合、ツールはスキップされ、LLMは「ユーザーによってツールの実行が拒否されました」のようなメッセージを受け取り、アプローチを調整できます。
判断は1回の正確な呼び出しのみを承認する
判断は、それが要求された単一の呼び出しに適用されます。ツール名をキーにすると、1回の承認ですべての同じツールの呼び出しが承認されてしまいます。そのため、スクラッチパス上のdelete_fileに対する承認によって、別の対象への呼び出しまで承認されることになります。したがって、1ターン内で同じツールを2回呼び出す場合は、2つの判断が必要です。
不明な呼び出しIDは「判断なし」を意味し、その呼び出しは確認待ちのままになります。失敗時の動作は常に、実行するのではなく再度確認を求める方向になります。
判断を引数にバインドする
ブラウザ、キュー、外部承認サービスなど、自分で制御できないものを通じて判断が伝達される場合、呼び出しIDが異なる引数で再利用される可能性があります。判断を、それが承認された引数にバインドしてください。
use adk_core::{RunConfig, ToolConfirmationDecision, tool_call_fingerprint};
use serde_json::json;
use std::collections::HashMap;
let approved_args = json!({ "path": "/data/report.csv" });
let mut decisions = HashMap::new();
decisions.insert("call_abc123".to_string(), ToolConfirmationDecision::Approve);
let mut fingerprints = HashMap::new();
fingerprints.insert(
"call_abc123".to_string(),
tool_call_fingerprint("delete_file", &approved_args),
);
let config = RunConfig::builder()
.tool_confirmation_decisions(decisions)
.tool_confirmation_fingerprints(fingerprints)
.build();
到着した呼び出しがフィンガープリントと一致しない場合、判断は無視され、その呼び出しは未確認として扱われます。tool_call_fingerprintではキーの順序が正規化されるため、引数オブジェクトを再シリアライズしても一致します。
ポリシーによって適用すべき決定を呼び出しごとではなく実装するには、静的マップを拡張する代わりに
ToolConfirmationHandlerを実装します。
CLIの例
ツールを実行する前に確認を求めるターミナルエージェント:
use adk_agent::LlmAgentBuilder;
use adk_core::{
Content, Event, RunConfig, ToolConfirmationDecision,
SessionId, UserId,
};
use adk_runner::Runner;
use adk_session::InMemorySessionService;
use adk_model::GeminiModel;
use adk_tool::tool;
use futures::StreamExt;
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
#[derive(Deserialize, JsonSchema)]
struct DeleteArgs {
/// File path to delete
path: String,
}
/// Delete a file from the filesystem.
#[tool]
async fn delete_file(args: DeleteArgs) -> Result<serde_json::Value, adk_core::AdkError> {
// In production, actually delete the file
Ok(serde_json::json!({"deleted": args.path}))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let api_key = std::env::var("GOOGLE_API_KEY")?;
let model = GeminiModel::new(&api_key, "gemini-3.7-flash")?;
let agent = LlmAgentBuilder::new("file-manager")
.model(Arc::new(model))
.instruction("You help manage files. Use delete_file when asked to remove files.")
.tool(Arc::new(DeleteFile))
.require_tool_confirmation("delete_file")
.build()?;
let session_service = Arc::new(InMemorySessionService::new());
let runner = Runner::new(adk_runner::RunnerConfig {
app_name: "file-manager".to_string(),
agent: Arc::new(agent),
session_service: session_service.clone(),
..Default::default()
})?;
let user_id = UserId::new("user-1")?;
let session_id = SessionId::new("session-1")?;
// Create session
session_service.create(adk_session::CreateRequest {
app_name: "file-manager".to_string(),
user_id: "user-1".to_string(),
session_id: Some("session-1".to_string()),
state: HashMap::new(),
}).await?;
println!("File Manager (type 'quit' to exit)");
loop {
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input == "quit" { break; }
let content = Content::new("user").with_text(input);
let mut stream = runner.run(
user_id.clone(), session_id.clone(), content,
).await?;
while let Some(result) = stream.next().await {
let event = result?;
// Check if the agent is requesting tool confirmation
if let Some(ref confirmation) = event.actions.tool_confirmation {
println!(
"\n⚠️ The agent wants to run '{}' with args: {}",
confirmation.tool_name,
serde_json::to_string_pretty(&confirmation.args)?
);
print!("Allow? [y/n]: ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let decision = if answer.trim().eq_ignore_ascii_case("y") {
ToolConfirmationDecision::Approve
} else {
ToolConfirmationDecision::Deny
};
// Re-run with the decision
let mut decisions = HashMap::new();
// Keyed by the call ID, so the decision authorizes only this call.
if let Some(call_id) = confirmation.function_call_id.clone() {
decisions.insert(call_id, decision);
}
let content = Content::new("user").with_text("");
let mut resume_stream = runner.run(
user_id.clone(), session_id.clone(), content,
).await?;
while let Some(result) = resume_stream.next().await {
let event = result?;
if let Some(ref content) = event.llm_response.content {
for part in &content.parts {
if let Some(text) = part.text() {
print!("{text}");
}
}
}
}
println!();
} else if let Some(ref content) = event.llm_response.content {
for part in &content.parts {
if let Some(text) = part.text() {
print!("{text}");
}
}
}
}
println!();
}
Ok(())
}
Web サーバーの例
イベントをフロントエンドにストリーミングするSSEエンドポイント。toolConfirmationイベントを受信すると、フロントエンドは承認ダイアログを表示し、決定を送り返します。
use adk_agent::LlmAgentBuilder;
use adk_core::{
Content, RunConfig, ToolConfirmationDecision, SessionId, UserId,
};
use adk_runner::Runner;
use adk_session::InMemorySessionService;
use axum::{Json, Router, extract::State, response::sse::{Event, Sse}};
use axum::routing::post;
use futures::StreamExt;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
runner: Arc<Runner>,
}
#[derive(Deserialize)]
struct ChatRequest {
message: String,
user_id: String,
session_id: String,
/// Tool confirmation decisions from the previous turn
#[serde(default)]
tool_decisions: HashMap<String, String>, // "tool_name" -> "approve"|"deny"
}
async fn chat_handler(
State(state): State<AppState>,
Json(req): Json<ChatRequest>,
) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
let runner = state.runner.clone();
let user_id = UserId::new(&req.user_id).unwrap();
let session_id = SessionId::new(&req.session_id).unwrap();
let content = Content::new("user").with_text(&req.message);
let stream = async_stream::stream! {
let mut event_stream = match runner.run(user_id, session_id, content).await {
Ok(s) => s,
Err(e) => {
yield Ok(Event::default().data(
serde_json::json!({"error": e.to_string()}).to_string()
));
return;
}
};
while let Some(result) = event_stream.next().await {
match result {
Ok(event) => {
// Emit tool confirmation request to frontend
if let Some(ref confirmation) = event.actions.tool_confirmation {
yield Ok(Event::default()
.event("tool_confirmation")
.data(serde_json::json!({
"toolName": confirmation.tool_name,
"args": confirmation.args,
"functionCallId": confirmation.function_call_id,
}).to_string()));
}
// Emit text content
if let Some(ref content) = event.llm_response.content {
for part in &content.parts {
if let Some(text) = part.text() {
yield Ok(Event::default()
.event("text")
.data(serde_json::json!({"text": text}).to_string()));
}
}
}
}
Err(e) => {
yield Ok(Event::default().data(
serde_json::json!({"error": e.to_string()}).to_string()
));
}
}
}
yield Ok(Event::default().event("done").data("{}".to_string()));
};
Sse::new(stream)
}
// Frontend JavaScript (conceptual):
//
// const source = new EventSource('/api/chat');
// source.addEventListener('tool_confirmation', (e) => {
// const data = JSON.parse(e.data);
// showConfirmDialog(data.toolName, data.args, (approved) => {
// fetch('/api/chat', {
// method: 'POST',
// body: JSON.stringify({
// message: '',
// tool_decisions: { [data.toolName]: approved ? 'approve' : 'deny' }
// })
// });
// });
// });
BeforeToolCallback
プログラムによる認可 — 権限の確認、外部認証サービスの呼び出し、または監査用のログ記録に使用します。ユーザーとの対話は必要ありません。
use adk_agent::LlmAgentBuilder;
use adk_core::{BeforeToolCallback, CallbackContext, Content};
use std::sync::Arc;
let agent = LlmAgentBuilder::new("assistant")
.model(model)
.tool(Arc::new(my_tool))
.before_tool_callback(Box::new(|ctx: Arc<dyn CallbackContext>| {
Box::pin(async move {
let tool_name = ctx.tool_name().unwrap_or("unknown");
let tool_input = ctx.tool_input();
// Log for audit
tracing::info!(tool = tool_name, "tool execution requested");
// Custom authorization logic
let user_scopes = ctx.user_scopes();
if tool_name == "admin_action" && !user_scopes.contains(&"admin".to_string()) {
// Return Some(Content) to skip the tool
return Ok(Some(
Content::new("tool")
.with_text("Permission denied: admin scope required")
));
}
Ok(None) // Allow execution
})
}))
.build()?;
戻り値:
Ok(None)— ツールの実行を許可するOk(Some(content))— ツールをスキップし、この内容をLLMに送信するErr(e)— エージェントの実行全体を中止する
アクセス制御
ロールベースの権限を持つエンタープライズRBAC向け。アクセス制御で詳細なドキュメントを確認してください。
use adk_auth::{AccessControl, Role, Permission, ToolExt};
let ac = AccessControl::builder()
.role(Role::new("analyst")
.allow(Permission::Tool("search".into()))
.allow(Permission::Tool("summarize".into()))
.deny(Permission::Tool("delete_file".into())))
.role(Role::new("admin")
.allow(Permission::AllTools))
.assign("alice@co.com", "admin")
.assign("bob@co.com", "analyst")
.build()?;
// Wrap tools with automatic permission checking
let protected_tool = my_tool.with_access_control(Arc::new(ac));
グラフ割り込み
実行状態を保持して後から再開する必要がある、複雑な承認ワークフロー向け。グラフエージェントで詳細なドキュメントを確認してください。
グラフエージェントはチェックポイントベースの割り込みをサポートしています。実行はノードで一時停止し、状態をチェックポイントストアに保持して、人間からの入力後に再開されます。サーバーの再起動をまたぐ場合でも同様です。
グラフネイティブなツール確認
AgentNodeは、CompiledGraphで実行される際にも、標準のツール確認ポリシーを維持します。グラフをRunnerイベントストリームに平坦化する代わりに、グラフは独自のフロンティアをチェックポイントに保存し、GraphToolConfirmationPause::from_stream_eventで読み取れる構造化されたカスタムイベントを発行します。
use adk_agent::LlmAgentBuilder;
use adk_core::{RunConfig, ToolConfirmationDecision};
use adk_graph::{
checkpoint::MemoryCheckpointer,
edge::{END, START},
graph::StateGraph,
node::{AgentNode, ExecutionConfig},
state::State,
interrupt::GraphToolConfirmationPause,
stream::StreamMode,
};
use futures::StreamExt;
use std::{collections::HashMap, sync::Arc};
let agent = LlmAgentBuilder::new("file_manager")
.model(model)
.tool(delete_file_tool)
.require_tool_confirmation("delete_file")
.build()?;
let graph = StateGraph::with_channels(&["messages"])
.add_node(AgentNode::new(Arc::new(agent)))
.add_edge(START, "file_manager")
.add_edge("file_manager", END)
.compile()?
.with_checkpointer(MemoryCheckpointer::new());
let mut events = Box::pin(graph.stream(
State::new(),
ExecutionConfig::new("delete-report"),
StreamMode::Debug,
));
let pause = loop {
match events.next().await.transpose()? {
Some(event) => {
if let Some(pause) = GraphToolConfirmationPause::from_stream_event(&event) {
break pause;
}
}
None => unreachable!("the graph must pause before the tool runs"),
}
};
// Present `pause.request.tool_name` and `pause.request.args` to the approver. A decision
// is scoped to this exact function call ID; bind its arguments as well when it
// crosses an untrusted boundary.
let call_id = pause.request.function_call_id.expect("LLM tool calls have an ID");
let decisions = HashMap::from([(call_id, ToolConfirmationDecision::Approve)]);
// The checkpoint is selected automatically by thread ID. `pause.checkpoint_id` is
// available for audit records or an explicit `with_resume_from` call.
drop(events);
let final_events = graph.stream_with_run_config(
State::new(),
ExecutionConfig::new("delete-report"),
StreamMode::Debug,
RunConfig::builder().tool_confirmation_decisions(decisions).build(),
);
# let _ = pause;
# let _ = final_events;
グラフはノードのライフサイクル、中間状態、ネストされたサブグラフ、保留中のフロンティアを保持します。確認リクエストと同時に完了したノードはチェックポイントに保存され、承認後に再実行されることはありません。エージェント自体は、通常の ADK 実行と同じ RunConfig の決定セマンティクスを使用します。
メカニズムの組み合わせ
これらのメカニズムは自然に組み合わせることができます。
let agent = LlmAgentBuilder::new("secure-assistant")
.model(model)
// RBAC: deny unauthorized users entirely
.tool(Arc::new(search_tool.with_access_control(Arc::new(ac))))
// Callback: audit all tool calls
.before_tool_callback(audit_callback())
// Confirmation: require human approval for destructive ops
.require_tool_confirmation("delete_file")
.require_tool_confirmation("send_email")
.build()?;
評価順序:
- RBAC チェック(
ProtectedToolラッパーが使用されている場合) — 権限のないユーザーを拒否 BeforeToolCallback— プログラムによるゲート処理。スキップまたは中止が可能ToolConfirmationPolicy— 必要に応じて人間による承認のために一時停止- ツールを実行
AfterToolCallback/AfterToolCallbackFull— 実行後の検査
関連項目
- アクセス制御 — RBAC、SSO、監査ログ
- コールバック — すべてのコールバック型とライフサイクル
- グラフエージェント — チェックポイントベースの割り込み
- ガードレール — 入力および出力の検証