Tools in Realtime-Sitzungen
Das prägende Merkmal eines Realtime-Agenten (im Gegensatz zu einem Voice Bot) ist, dass er mitten in der Konversation echte Aktionen ausführen kann: etwas nachschlagen, eine Rückerstattung bearbeiten, an einen Menschen übergeben — und dann das Ergebnis aussprechen. Tools laufen serverseitig, sodass deine Geschäftslogik und Anmeldedaten niemals den Client berühren.
Wie ein Tool-Durchlauf abläuft
- Das Modell entscheidet, dass es ein Tool benötigt, und gibt
FunctionCallDone { name, arguments, call_id }aus. RealtimeRunnersucht den Handler fürnameund führt ihn aus.- Das JSON-Ergebnis des Handlers wird als Tool-Output an das Modell zurückgesendet.
- Der Runner löst eine nachfolgende Antwort aus; das Modell spricht die Antwort und stützt sich dabei auf das Ergebnis.
Du rufst dafür nie create_response() auf — der Runner übernimmt den Roundtrip, wenn auto_respond_tools aktiviert ist (der Standard).
Native Tools: ToolDefinition + FnToolHandler
Der schlanke Weg. Ein ToolDefinition ist das JSON-Schema, das das Modell sieht; ein FnToolHandler ist ein synchroner Closure, der ausgeführt wird, wenn er aufgerufen wird.
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}.") }))
})
}
Registriere es mit .tool(definition, handler) am 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()?;
Der Handler gibt ein serde_json::Value zurück; was immer du zurückgibst, ist das, was das Modell sieht, also füge eine menschenlesbare message hinzu, die der Agent paraphrasieren kann.
Handler laufen serverseitig und synchron innerhalb des Event Loops. Halte sie kurz; für langsame Arbeit gib einen "started"-Status zurück und folge außerhalb des normalen Ablaufs nach.
Gebrückte Tools: jedes adk_core::Tool
Wenn du bereits adk-core-Tools hast (deine eigenen FunctionTools oder adk-tool-eingebaute wie die Knowledge-Graph-remember/relate), hänge sie mit .adk_tool(...) an — ohne Rewrite. Die Integrationsschicht verpackt jedes davon in einen ToolHandler und erzeugt ein ToolContext, das auf das (app_name, user_id, session_id) der Sitzung beschränkt ist:
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()?;
So kuratiert der Agent seinen eigenen Speicher. Der Bridge eignet sich gut für lokal ausgeführte, kontextunabhängige Tools; Tools, die einen reichen Agentenstatus benötigen, sollten besser als native FnToolHandlers geschrieben werden.
Parallele Tool-Aufrufe
Ein Modell kann mehrere Tools in einer Antwort anfordern (z. B. "Wie ist das Wetter und wie spät ist es in London?"). ADK-Rust behandelt das korrekt: Es sendet die Ausgabe jedes Tools, sobald sie fertig ist, und löst dann genau eine response.create aus, sobald die Dispatch-Antwort abgeschlossen ist.
Das ist wichtig, weil der naive Ansatz — eine Antwort pro Tool auszulösen — den Fehler von OpenAI "conversation already has an active response in progress" auslöst und die Sitzung blockiert. Der Runner vermeidet das, indem er "Tool-Output senden" (send_tool_output) von "die Antwort auslösen" (respond_after_tools, einmal auf dem Dispatch ResponseDone aufgerufen) trennt. Du bekommst das kostenlos; sei dir nur bewusst, wenn du Ereignisse liest, dass ein Tool-Durchlauf zwei Antworten umfasst
(siehe Architektur).
Tool-Ereignisse in einer UI lesen
Um Tool-Aktivität anzuzeigen (z. B. einen "Refund wird bearbeitet…"-Chip), achte auf FunctionCallDone:
ServerEvent::FunctionCallDone { name, arguments, .. } => {
// `arguments` is a JSON string of the call args
ui_show_tool_activity(&name, &arguments);
}
Die gesprochene Bestätigung erscheint danach als TranscriptDelta, sobald das Tool-Ergebnis in die nachfolgende Antwort eingearbeitet wurde.
Sieh es in Aktion
Das Beispiel customer_service verbindet
process_refund und connect_to_human; das Beispiel realtime_tools
ist ein headless Probe-Lauf, der Single-Tool-, Parallel-Tool- und
Rechner-Durchläufe bei beiden Anbietern ausführt.
Weiter: Multimodal →
Welche Tools verwaltet werden
IntegratedRealtimeRunner leitet Tool-Aufrufe danach weiter, wie das Tool registriert wurde:
| Registriert als | Weiterleitung | Angewendete Richtlinie |
|---|---|---|
adk_tool(...) — ein ADK Tool | Die Integrationsrichtlinien-Pipeline | Konfigurierte Plugins, Protokollaufzeichnung, Persistenz von Tool-Ereignissen |
| Ein nativer Echtzeit-Handler | RealtimeRunner dispatch | Keine — der Handler ist von vornherein vertrauenswürdig |
Ein ADK-Tool erreichte den Provider zuvor über ein ToolBridgeAdapter, das einen Kontext erstellt und Tool::execute ohne Plugins, Callbacks oder Bestätigung aufruft. Ein Tool, das im standardmäßigen Agenten-Loop gesteuert wird, lief daher unkontrolliert in Echtzeit. Der Native-Handler-Bypass ist jetzt die explizite Ausnahme statt die Standardeinstellung für alles.
Plugin-Fehler schlagen geschlossen fehl
Wenn die before_tool_call-Pipeline einen Fehler zurückgibt, wird das Tool abgelehnt:
{ "error": "tool guarded was refused: its before-tool plugin pipeline failed (...). Execution is refused rather than proceeding without policy." }
Wichtig: Dieser Pfad protokollierte den Plugin-Fehler zuvor als nicht fatal und führte dann das Tool aus. Autorisierung, Redaction und Richtlinien liegen in Before-Tool-Plugins, sodass ein defekter Guard zu keinem Guard wurde.
After-Tool-Plugin-Fehler lassen das eigene Ergebnis des Tools unverändert, da das Tool bereits ausgeführt wurde.
Tool-Callbacks am direkten Agenten
RealtimeAgent wendet Before- und After-Tool-Callbacks mit demselben Vertrag wie der standardmäßige Agenten-Loop an:
| Rückgabewert des Callback | Wirkung |
|---|---|
Ok(None) | Das Tool wird ausgeführt |
Ok(Some(content)) aus einem before-Callback | Der Inhalt wird zum Ergebnis; das Tool wird nicht ausgeführt |
Err(e) aus einem before-Callback | Der Fehler wird zum Ergebnis, das Tool wird nicht ausgeführt und after-Callbacks werden übersprungen |
Ok(Some(content)) aus einem after-Callback | Der Inhalt ersetzt das Ergebnis des Tools |
Err(e) aus einem after-Callback | Der Fehler ersetzt das Ergebnis des Tools |
Ein Content eines Callbacks wird in das vom Provider erwartete JSON-Ergebnis umgewandelt: Ein FunctionResponse-Teil trägt seine Nutzlast bei, alles andere trägt seinen Text unter einem result-Schlüssel bei.
Wichtig: Bevor dieser Vertrag eingehalten wurde, wurde die Entscheidung eines Before-Callbacks berechnet und verworfen, sodass das Tool trotzdem ausgeführt wurde — ein Gate, das eine Verweigerung meldete, ohne eine durchzusetzen. Ergebnisse von After-Callbacks, einschließlich Fehlern, wurden verworfen.
Realtime-Tool-Kontext
Ein aus RealtimeAgent aufgerufenes Tool sieht dieselben Fähigkeiten, die es unter einem Runner sieht:
| Fähigkeit | Quelle |
|---|---|
user_scopes() | Der Kontext des übergeordneten Aufrufs |
get_secret(name) | Der Kontext des übergeordneten Aufrufs |
shared_state() | Der Aufrufkontext des Elternteils |
search_memory(query) | Der Memory-Service des Elternteils |
Identität (app_name, user_id, session_id, branch) | Der Aufrufkontext des Elternteils |
Hinweis: diese fielen zuvor auf die Trait-Defaults zurück — eine leere Scope-Liste,
Nonefür Secrets undNonefür geteilten Status — sodass sich ein Tool zur Scope- oder Secret-Prüfung in Echtzeit anders verhielt als unter einem Runner und nicht zwischen einem nicht authentifizierten Aufrufer und einem Kontext unterscheiden konnte, der Scopes einfach nicht weitergereicht hat.
Tool-Konkurrenz
RunnerConfig::max_concurrent_tools (standardmäßig 4) begrenzt, wie viele Tool-Handler gleichzeitig laufen. Wenn eine Antwort mehrere Aufrufe auslöst, reiht der Runner jeden in seine Event-Schleife ein
und lässt ihn zur Ausführung zu, sobald ein Permit frei wird:
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()?;
Daraus folgen zwei Eigenschaften, und beide sind durch Tests abgedeckt:
- Die Event-Aufnahme läuft während der Tool-Ausführung weiter. Audio-Deltas, Transkripte und Unterbrechungen werden verarbeitet, während Tools laufen. Ein Handler, der auf etwas wartet, das später in der Session ankommt, blockiert die Session nicht mehr dauerhaft.
- Eine Follow-up-Antwort, nach der letzten Ausgabe. Wenn Tool-Ausgabe automatisch gesendet wird,
steht dem Modell genau eine
create_responsezu. Sie wird erst ausgegeben, wenn sowohl die auslösende Antwort geschlossen wurde als auch jedes ausgelöste Tool gemeldet hat — in beliebiger Reihenfolge, da eine Antwort jetzt schließen kann, während Tools noch laufen.
Wichtig: Die Grenze steuert Konkurrenz, nicht Parallelität. Handler teilen sich die Aufgabe des Runners, sodass ein Handler, der den Thread blockiert — synchrones Datei- oder Netzwerk-I/O, schwere Berechnungen — die Schleife weiterhin stoppt. Verwende dafür
tokio::task::spawn_blocking.
Trennungsrichtlinie
Der Runner verbindet nicht automatisch erneut. Bei einem Transportverlust lässt er ausgelöste Tools
zu Ende laufen, ruft EventHandler::on_disconnect auf und kehrt von run zurück:
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(())
}
}
Die Wiederverbindung bleibt beim Aufrufer, weil dafür entschieden werden muss, welcher Kontext
wiedergegeben werden soll und, bei Gemini, ob ein gespeicherter Fortsetzungstoken noch gültig ist. Der on_disconnect-Hook
existiert, damit sich Transportverlust von einem ordnungsgemäßen close unterscheiden lässt — run gibt
Ok(()) für beides zurück.