Outils dans les sessions en temps réel

La caractéristique déterminante d’un agent en temps réel (par opposition à un bot vocal) est qu’il peut effectuer de vraies actions au milieu de la conversation : chercher une information, traiter un remboursement, passer la main à un humain — puis énoncer le résultat. Les outils s’exécutent côté serveur, donc votre logique métier et vos identifiants n’atteignent jamais le client.

Comment s’enchaîne un tour d’outil

  1. Le modèle décide qu’il a besoin d’un outil et émet FunctionCallDone { name, arguments, call_id }.
  2. RealtimeRunner recherche le gestionnaire pour name et l’exécute.
  3. Le résultat JSON du gestionnaire est renvoyé au modèle sous forme de sortie d’outil.
  4. Le runner déclenche une seule réponse de suivi ; le modèle prononce la réponse, fondée sur le résultat.

Vous n’appelez jamais create_response() pour cela — le runner gère l’aller-retour lorsque auto_respond_tools est activé (par défaut).

Outils natifs : ToolDefinition + FnToolHandler

La voie légère. Un ToolDefinition est le schéma JSON que le modèle voit ; un FnToolHandler est une fermeture synchrone qui s’exécute lorsqu’on l’appelle.

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}.") }))
    })
}

Enregistrez-le sur le builder avec .tool(definition, handler) :

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()?;

Le gestionnaire renvoie un serde_json::Value ; tout ce que vous retournez est ce que le modèle voit, donc incluez un message lisible par l’humain que l’agent peut paraphraser.

Les gestionnaires s’exécutent côté serveur et de manière synchrone dans la boucle d’événements. Gardez-les rapides ; pour un travail lent, renvoyez un statut « démarré » et faites un suivi hors bande.

Outils bridgés : tout adk_core::Tool

Si vous avez déjà des outils adk-core (vos propres FunctionTool, ou des éléments intégrés adk-tool comme les remember/relate du graphe de connaissances), attachez-les avec .adk_tool(...) — aucune réécriture. La couche d’intégration encapsule chacun d’eux dans un ToolHandler et synthétise un ToolContext limité au (app_name, user_id, session_id) de la session :

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()?;

C’est ainsi que l’agent organise sa propre mémoire. Le pont convient bien aux outils exécutés localement et indépendants du contexte ; les outils qui ont besoin d’un état d’agent riche sont mieux écrits comme des FnToolHandler natifs.

Appels parallèles d’outils

Un modèle peut demander plusieurs outils dans une seule réponse (par ex. « quelle est la météo et l’heure à Londres ? »). ADK-Rust gère cela correctement : il envoie la sortie de chaque outil au fur et à mesure qu’elle est prête, puis émet exactement une response.create une fois que la réponse de dispatch est terminée.

Cela compte parce que l’approche naïve — déclencher une réponse par outil — provoque l’erreur OpenAI « conversation déjà dotée d’une réponse active en cours » et bloque la session. Le runner l’évite en séparant « envoyer la sortie de l’outil » (send_tool_output) de « déclencher la réponse » (respond_after_tools, appelé une fois sur le ResponseDone de dispatch). Vous bénéficiez de cela gratuitement ; gardez simplement à l’esprit, lors de la lecture des événements, qu’un tour d’outil s’étend sur deux réponses (voir Architecture).

Lecture des événements d’outil dans une interface utilisateur

Pour afficher l’activité des outils (par ex. un badge « Traitement du remboursement… »), surveillez FunctionCallDone :

ServerEvent::FunctionCallDone { name, arguments, .. } => {
    // `arguments` is a JSON string of the call args
    ui_show_tool_activity(&name, &arguments);
}

La confirmation verbale arrive ensuite sous forme de TranscriptDelta une fois que le résultat de l’outil est intégré à la réponse de suivi.

Le voir en action

L’exemple customer_service assemble process_refund et connect_to_human ; l’exemple realtime_tools est une sonde sans interface qui teste les tours à outil unique, les tours à outils parallèles et les tours de calculatrice chez les deux fournisseurs.

Suivant : Multimodal →

Quels outils sont régis

IntegratedRealtimeRunner achemine les appels d’outils selon la manière dont l’outil a été enregistré :

Enregistré commeAcheminementPolitique appliquée
adk_tool(...) — un ADK ToolLe pipeline de politique d'intégrationPlugins configurés, enregistrement de la transcription, persistance des événements d'outil
Un gestionnaire temps réel natifacheminement RealtimeRunnerAucune — le gestionnaire est de confiance par construction

Un outil ADK atteignait auparavant le fournisseur via un ToolBridgeAdapter, qui crée un contexte et appelle Tool::execute sans plugins, callbacks ni confirmation. Un outil régi dans la boucle d’agent standard s’exécutait donc sans contrôle en temps réel. Le contournement du gestionnaire natif est désormais l’exception explicite plutôt que le comportement par défaut pour tout.

Les échecs de plugins échouent de manière fermée

Si le pipeline before_tool_call renvoie une erreur, l’outil est refusé :

{ "error": "tool guarded was refused: its before-tool plugin pipeline failed (...). Execution is refused rather than proceeding without policy." }

Important : ce chemin consignait auparavant l’erreur du plugin comme non fatale, puis exécutait l’outil. L’autorisation, la redaction et la policy vivent dans les plugins avant l’outil, donc une garde défaillante devenait une absence de garde.

Les erreurs des plugins après l’outil laissent en place le propre résultat de l’outil, puisque l’outil a déjà été exécuté.

Callbacks d’outil sur l’agent direct

RealtimeAgent applique les callbacks avant et après l’outil avec le même contrat que la boucle d’agent standard :

Valeur de retour du callbackEffet
Ok(None)L'outil s'exécute
Ok(Some(content)) d'un callback beforeLe contenu devient le résultat ; l'outil ne s'exécute pas
Err(e) depuis un rappel beforeL'erreur devient le résultat, l'outil ne s'exécute pas, et les rappels after sont ignorés
Ok(Some(content)) depuis un rappel afterLe contenu remplace le résultat de l'outil
Err(e) depuis un rappel afterL'erreur remplace le résultat de l'outil

La Content d'un callback est convertie en le résultat JSON attendu par le fournisseur : une partie FunctionResponse fournit sa charge utile, tout le reste fournit son texte sous une clé result.

Important : avant que ce contrat ne soit respecté, la décision d'un before-callback était calculée puis ignorée, si bien que l'outil s'exécutait quoi qu'il arrive — un garde qui signalait un refus sans l'appliquer. Les résultats des after-callback, y compris les erreurs, étaient ignorés.

Contexte d'outil en temps réel

Un outil invoqué depuis RealtimeAgent voit les mêmes capacités qu'il voit sous un Runner :

CapacitéSource
user_scopes()Le contexte d'appel parent
get_secret(name)Le contexte d'appel parent
shared_state()Le contexte d’invocation parent
search_memory(query)Le service de mémoire du parent
Identité (app_name, user_id, session_id, branch)Le contexte d’invocation parent

Remarque : ceux-ci tombaient auparavant sur les valeurs par défaut du trait — une liste de scopes vide, None pour les secrets, et None pour l’état partagé — de sorte qu’un outil vérifiant les scopes ou les secrets se comportait différemment en temps réel que sous un Runner, et ne pouvait pas distinguer un appelant non authentifié d’un contexte qui avait simplement omis de transmettre les scopes.

Concurrence des outils

RunnerConfig::max_concurrent_tools (par défaut 4) borne le nombre de gestionnaires d’outils exécutés en même temps. Lorsqu’une réponse déclenche plusieurs appels, le runner les met en file sur sa boucle d’événements et les admet à l’exécution à mesure qu’une permission se libère :

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()?;

Deux propriétés en découlent, et elles sont toutes deux couvertes par des tests :

  • La réception des événements continue pendant l’exécution des outils. Les deltas audio, les transcriptions et les interruptions sont gérés pendant que les outils s’exécutent. Un gestionnaire qui attend qu’un élément arrive plus tard dans la session ne bloque plus la session.
  • Une seule réponse de suivi, après la dernière sortie. Lorsque la sortie d’un outil est envoyée automatiquement, le modèle a droit à un unique create_response. Il est émis une fois que la réponse qui déclenche a été փակée et que chaque outil déclenché a répondu — dans n’importe quel ordre, puisqu’une réponse peut désormais se fermer pendant que les outils sont encore en cours d’exécution.

Important : la borne régit la concurrence, pas le parallélisme. Les gestionnaires partagent la tâche du runner, donc un gestionnaire qui bloque le thread — E/S de fichier ou réseau synchrones, calcul intensif — ralentit toujours la boucle. Utilisez tokio::task::spawn_blocking pour ceux-là.

Politique de déconnexion

Le runner ne se reconnecte pas automatiquement. En cas de perte de transport, il laisse les outils déclenchés se terminer, appelle EventHandler::on_disconnect, et retourne depuis run :

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(())
    }
}

La reconnexion reste à la charge de l’appelant car elle exige de décider quel contexte rejouer et, sur Gemini, de déterminer si un jeton de reprise stocké est toujours valide. Le hook on_disconnect existe afin que la perte de transport puisse être distinguée d’une close gracieuse — run retourne Ok(()) pour les deux.