Realtime セッションにおけるツール
realtime agent(音声ボットと対比した場合)の決定的な特徴は、会話の途中で実際のアクションを実行できることです。たとえば、何かを検索する、返金処理を行う、人間に引き継ぐ——そして結果を話すことができます。ツールはサーバー側で実行されるため、ビジネスロジックや認証情報がクライアントに触れることはありません。
ツールのターンの流れ
- モデルはツールが必要だと判断し、
FunctionCallDone { name, arguments, call_id }を出力します。 RealtimeRunnerはnameのハンドラを探して実行します。- ハンドラのJSON結果は、ツール出力としてモデルに送り返されます。
- runner は1回だけフォローアップ応答を起動し、モデルは結果に基づいた回答を話します。
このためにcreate_response()を呼び出す必要はありません。auto_respond_toolsが有効(デフォルト)であれば、runner が往復処理を担当します。
ネイティブツール: ToolDefinition + FnToolHandler
軽量な方法です。ToolDefinitionはモデルが見るJSONスキーマであり、FnToolHandlerは呼び出されたときに実行される同期クロージャです。
use adk_realtime::config::ToolDefinition;
use adk_realtime::events::ToolCall;
use adk_realtime::runner::FnToolHandler;
use serde_json::json;
fn process_refund_def() -> ToolDefinition {
ToolDefinition {
name: "process_refund".into(),
description: Some("Issue a refund for an order. Only when clearly warranted.".into()),
parameters: Some(json!({
"type": "object",
"properties": {
"order_id": { "type": "string", "description": "e.g. 'A-10293'" },
"reason": { "type": "string", "description": "Short reason" }
},
"required": ["order_id", "reason"]
})),
}
}
fn process_refund_tool()
-> FnToolHandler<impl Fn(&ToolCall) -> adk_realtime::error::Result<serde_json::Value> + Send + Sync> {
FnToolHandler::new(|call: &ToolCall| {
let order = call.arguments.get("order_id").and_then(|v| v.as_str()).unwrap_or("unknown");
// …do the work…
Ok(json!({ "status": "approved", "order_id": order,
"message": format!("Refund approved for {order}.") }))
})
}
.tool(definition, handler)で builder に登録します:
let runner = IntegratedRealtimeRunner::builder()
.model(model)
.config(config)
.identity("support", "customer", &session_id)
.session_service(sessions)
.tool(process_refund_def(), process_refund_tool())
.tool(connect_to_human_def(), connect_to_human_tool())
.build()?;
ハンドラはserde_json::Valueを返します。返した内容がそのままモデルに見えるため、エージェントが言い換えられる人間向けのmessageを含めてください。
ハンドラはイベントループ内でサーバー側かつ同期的に実行されます。短時間で終わるようにしてください。遅い処理の場合は、「started」ステータスを返して、あとで別経路で続報を送ってください。
ブリッジされたツール: 任意のadk_core::Tool
すでにadk-coreツール(独自のFunctionToolや、ナレッジグラフのadk-tool組み込み機能のようなremember/relateなど)を持っているなら、.adk_tool(...)で追加できます——書き換えは不要です。統合レイヤーは各ツールをToolHandlerでラップし、セッションの(app_name, user_id, session_id)にスコープされたToolContextを生成します:
use adk_tool::{RememberTool, RelateTool};
let runner = IntegratedRealtimeRunner::builder()
.model(model).config(config).identity("app", "user", &sid)
.memory_service(kg.clone())
.adk_tool(Arc::new(RememberTool::new(kg.clone()))) // adk_core::Tool
.adk_tool(Arc::new(RelateTool::new(kg)))
.tool(get_weather_def(), get_weather()) // native handler — mix freely
.build()?;
これが、エージェントが自分自身のメモリを管理する方法です。ブリッジは、ローカルで実行され、コンテキストに依存しないツールに向いています。豊富なエージェント状態を必要とするツールは、ネイティブなFnToolHandlerとして実装するほうが適しています。
ツールの並列呼び出し
モデルは1回の応答で複数のツールを要求できます(例: 「ロンドンの天気と時刻は?」)。ADK-Rustはこれを正しく処理します。各ツールの出力を完了次第送信し、ディスパッチ応答が終わったあとにちょうど1回のresponse.createを発行します。
これは重要です。なぜなら、素朴な方法——ツールごとに応答を発行する方法——ではOpenAIの "conversation already has an active response in progress" エラーにぶつかり、セッションが停止するからです。runner は「ツール出力を送る」(send_tool_output)ことと「応答を起動する」(respond_after_tools、ディスパッチResponseDoneで1回だけ呼ばれる)ことを分離することで、これを回避します。これは自動で行われますが、イベントを読むときには、ツールのターンが2つの応答にまたがることに注意してください
(アーキテクチャを参照)。
UI でツールイベントを読む
ツールの動作(例: 「返金を処理中…」のチップ)を表示するには、FunctionCallDoneを監視します:
ServerEvent::FunctionCallDone { name, arguments, .. } => {
// `arguments` is a JSON string of the call args
ui_show_tool_activity(&name, &arguments);
}
音声による確認は、その後ツール結果がフォローアップ応答に取り込まれたあとでTranscriptDeltaとして届きます。
動作を確認する
customer_serviceの例では、process_refundとconnect_to_humanを接続しています。realtime_toolsの例はヘッドレスの検証用で、両方のプロバイダーで単一ツール、並列ツール、calculator の各ターンを試します。
次へ: Multimodal →
どのツールが管理対象か
IntegratedRealtimeRunnerは、ツールがどのように登録されたかに基づいてツール呼び出しを振り分けます:
| 登録名 | ディスパッチ | 適用されるポリシー |
|---|---|---|
adk_tool(...) — ある ADK Tool | 統合ポリシーパイプライン | 設定済みプラグイン、トランスクリプト記録、ツールイベント永続化 |
| ネイティブなリアルタイムハンドラー | RealtimeRunner ディスパッチ | なし — ハンドラーは構成上信頼されている |
An ADK ツールは以前、ToolBridgeAdapter を通じてプロバイダーに到達していました。これはコンテキストを作成し、プラグイン、コールバック、または確認なしで Tool::execute を呼び出します。そのため、標準のエージェントループで管理されるツールが、リアルタイムでは管理されないまま実行されていました。ネイティブハンドラのバイパスは、今ではすべてに対する既定値ではなく、明示的な例外です。
プラグインの失敗は閉じる方向に失敗する
before_tool_call パイプラインがエラーを返した場合、そのツールは 拒否 されます:
{ "error": "tool guarded was refused: its before-tool plugin pipeline failed (...). Execution is refused rather than proceeding without policy." }
重要: この経路は以前、プラグインのエラーを非致命的としてログに記録してから、ツールを実行していました。認可、redaction、ポリシーは before-tool プラグインに存在するため、壊れたガードはガードなしと同じになっていました。
after-tool プラグインのエラーでは、ツール自体の結果はそのまま維持されます。これは、ツールがすでに実行済みだからです。
直接エージェント上のツールコールバック
RealtimeAgent は、standard agent loop と同じ契約で before-tool および after-tool コールバックを適用します:
| コールバックの戻り値 | 効果 |
|---|---|
Ok(None) | ツールが実行される |
Ok(Some(content)) from a before callback | 内容が結果になる; ツールは実行されない |
Err(e) の before コールバックから | エラーが結果になり、tool は実行されず、after コールバックはスキップされる |
Ok(Some(content)) の after コールバックから | コンテンツが tool の結果を置き換える |
Err(e) の after コールバックから | エラーが tool の結果を置き換える |
コールバックのContentは、プロバイダが期待するJSON結果に変換されます。FunctionResponseの部分はそのペイロードを提供し、それ以外はresultキーの下にそのテキストを提供します。
重要: この契約が遵守される前は、before-callback の判定が計算されて破棄されていたため、ツールは拒否を報告するゲートでありながら実際には拒否を強制せず、常に実行されていました。after-callback の結果も、エラーを含めて破棄されていました。
リアルタイムのツールコンテキスト
RealtimeAgentから呼び出されたツールは、Runnerの下で見えるのと同じ機能を見ます:
| 機能 | ソース |
|---|---|
user_scopes() | 親の呼び出しコンテキスト |
get_secret(name) | 親の呼び出しコンテキスト |
shared_state() | 親の呼び出しコンテキスト |
search_memory(query) | 親のメモリサービス |
Identity (app_name, user_id, session_id, branch) | 親の呼び出しコンテキスト |
注: これらは以前、trait のデフォルトにフォールスルーしていました。つまり、空の scope リスト、
Noneが secrets 用、Noneが共有 state 用です。そのため、scope または secret をチェックする tool は realtime では Runner の下で実行した場合と異なる振る舞いをし、認証されていない caller と、scope を単に引き渡し損ねた context とを区別できませんでした。
Tool の concurrency
RunnerConfig::max_concurrent_tools(デフォルト 4)は、同時に実行される tool handler の数を制限します。response が複数の call を dispatch すると、runner はそれぞれを event loop に queue し、permit が空き次第 execution を許可します。
use adk_realtime::{RealtimeRunner, RunnerConfig};
let runner = RealtimeRunner::builder()
.model(model)
.runner_config(RunnerConfig {
auto_execute_tools: true,
auto_respond_tools: true,
max_concurrent_tools: 3,
})
.build()?;
ここから 2 つの特性が導かれ、どちらも test で確認されています。
- Tool 実行中も event intake は継続する。 audio delta、transcript、interrupt は tool が動作している間も処理されます。session の後で到着する何かを待つ handler でも、もはや session を deadlock させません。
- 最後の output の後に、1 回だけ follow-up response。 tool output が自動送信される場合、model に対しては単一の
create_responseが必要です。これは、dispatch した response が close し、かつ dispatch されたすべての tool が report した後に 1 回だけ発行されます。順序はどちらでも構いません。response は tool がまだ実行中でも close できるようになったためです。
重要: この上限は parallelism ではなく concurrency を制御します。handler は runner の task を共有するため、thread を block する handler — 同期的な file や network の I/O、重い計算 — は、依然として loop を停止させます。それらには
tokio::task::spawn_blockingを使ってください。
Disconnect ポリシー
runner は自動では reconnect しません。transport が失われると、dispatch 済みの tool を完了させ、EventHandler::on_disconnect を呼び出し、run から return します。
use adk_realtime::{EventHandler, Result};
struct Reconnecting;
#[async_trait::async_trait]
impl EventHandler for Reconnecting {
async fn on_disconnect(&self) -> Result<()> {
tracing::warn!("realtime transport ended");
Ok(())
}
}
reconnect の責任は caller 側に残ります。というのも、どの context を replay するかを決める必要があり、Gemini では保存済みの resumption token がまだ有効かどうかも判断しなければならないからです。on_disconnect hook は、transport loss を、正常な close と区別できるようにするためにあります。run は両方に対して Ok(()) を返します。