L'article précédent, 3.8 Live de la voix à l'outil, ne dessinait que les cinq sauts. Celui-ci est daté du 23/09/2026. Contre la page outils Live officielle (dernière mise à jour le 15/09/2026) et la référence WebSocket, on démonte toolCall, args et FunctionResponse pour les recetter. L'API Live n'exécute pas les outils. Forme du pipeline : article précédent. Pourquoi la couche contrat reste un objet : Pourquoi l'IA aime JSON.
L'utilisateur dit « allume la lumière du salon ». Quand le modèle tend la main, vous ne recevez pas une transcription, mais un objet déjà parsé : id, name, args. Oublier l'id, passer args comme chaîne à l'exécuteur, ou inventer un nouvel id au retour : l'audio peut encore dire « d'accord », la lumière s'est déjà allumée dans la mauvaise pièce. Côté Agent texte, c'est required_actions → tool_result. Même forme ; l'enveloppe est un événement Live. Voir Pourquoi l'Agents API a encore plus besoin de JSON.
En quoi ça diffère de l'article précédent
L'article précédent s'arrêtait à « l'audio entre, un objet toolCall sort ». Celui-ci fait trois choses :
- Extraire
functionCalls[]du SDK ou du WebSocket. - Traiter
argscomme un objet. Refuser avec un Schema les champs trop larges ou mal reconnus. - Assembler
FunctionResponseavec le mêmeid/name. Traiter l'annulation si elle arrive.
Au 23/09/2026, le modèle stable reste gemini-3.8-live. Live ne prend pas en charge Structured Output. BidiGenerateContentSetup.generationConfig n'avale pas non plus responseSchema / responseJsonSchema. Ce que vous pouvez figer : le Schema des paramètres de fonction, et la validation de args avant exécution. La couche contrat de réponse : Qu'est-ce que la Structured Output.
En une phrase. Parser, ce n'est pas print tout le message. C'est d'abord obtenir l'objet, puis décider si on tend la main.
Les trois JSON à parser
La référence WebSocket plie les enveloppes client en quatre : setup, clientContent, realtimeInput, toolResponse. La boucle d'outils utilise la dernière. Le serveur pose toolCall ou toolCallCancellation sur BidiGenerateContentServerMessage. Ne mélangez pas avec le JSON WordInfo de la transcription ; voir 3.5 Transcribe.
Figer d'abord le contrat de champs :
{
"incoming": {
"toolCall": {
"functionCalls": [
{ "id": "string", "name": "string", "args": {} }
]
}
},
"outgoing": {
"toolResponse": {
"functionResponses": [
{ "id": "string", "name": "string", "response": {} }
]
}
},
"cancel": {
"toolCallCancellation": {
"ids": ["string"]
}
}
}
Le SDK Python plie le camelCase en snake_case : response.tool_call.function_calls, session.send_tool_response(function_responses=...). Le SDK JS garde le camelCase : turn.toolCall.functionCalls, session.sendToolResponse({ functionResponses }). Sur un WebSocket brut, les champs JSON suivent le camelCase de la référence.
Extraire toolCall de la session
La page outils Live officielle dit net la responsabilité client : déclarer les fonctions dans la config de session ; à la réception d'un appel, renvoyer un ensemble de FunctionResponse via session.send_tool_response. Contrairement à generateContent, pas de tool response automatique. Forme de la boucle : Agents API vs MCP — là-bas c'est un harness ; ici, c'est votre propre receive().
Traitez-le d'abord comme un événement. Ne commencez pas par le tampon audio :
async for response in session.receive():
if response.tool_call:
for fc in response.tool_call.function_calls:
call_id = fc.id
name = fc.name
args = fc.args or {}
# 下一步:校验 args,再决定执不执行
...
await session.send_tool_response(function_responses=function_responses)
Une trame WebSocket brute est un objet JSON. Le message serveur n'émet pas de messageType à part ; le champ est au sommet :
{
"toolCall": {
"functionCalls": [
{
"id": "call_light_01",
"name": "set_light",
"args": {
"room": "living_room",
"on": true
}
}
]
}
}
L'ordre de parsing est fixe : confirmer que le sommet est toolCall → prendre le tableau → lire trois champs par entrée. functionCalls manquant, pas un tableau, ou une entrée sans id / name : toute la trame est mauvaise. Ne devinez pas. Un prompt peut émettre plusieurs appels en parallèle, donc il faut parcourir. Ne prenez pas seulement [0].
Lire FunctionCall champ par champ
Le FunctionCall de la référence n'a que trois jobs :
id: la clé de cette main tendue. Retour, annulation et journaux s'en servent. Ne le réécrivez pas. N'en inventez pas un autre.name: doit tomber dans unfunctionDeclarationsdéjà envoyé. Nom inconnu : renvoyez une erreur. Pas de correspondance floue.args: un objet. C'est le contrat. Vide ={}. Ne traitez pasnullcomme « pas de paramètres ».
Les déclarations restent dans config.tools de la session. L'exemple officiel minimal n'a que name. En production, ajoutez au moins description et parameters :
{
"functionDeclarations": [
{
"name": "set_light",
"description": "Set one allowlisted room light. Use when the user asks to turn a light on or off.",
"behavior": "NON_BLOCKING",
"parameters": {
"type": "OBJECT",
"properties": {
"room": {
"type": "STRING",
"enum": ["living_room", "kitchen", "bedroom"],
"description": "Allowlisted room id. Do not invent rooms."
},
"on": { "type": "BOOLEAN" }
},
"required": ["room", "on"]
}
}
]
}
3.8 Live est NON_BLOCKING par défaut. S'il faut attendre le résultat avant la phrase suivante, mettez cet outil en BLOCKING. La variante extended-thinking n'autorise que l'asynchrone. La forme des permissions est la même qu'un Agent texte : limiter les permissions d'outils avec un Schema.
Valider args avant d'exécuter
La voix rend la seconde couche plus dangereuse. Un lapsus, un accent, un nom de pièce entendu comme une autre valeur d'enum : le modèle peut encore émettre un args d'apparence légale. Sans Schema de réponse pour rattraper, la validation des paramètres avant exécution est la seule porte dure.
Repliez la déclaration en un Schema que vous pouvez faire tourner dans l'espace de travail :
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["room", "on"],
"properties": {
"room": {
"type": "string",
"enum": ["living_room", "kitchen", "bedroom"]
},
"on": { "type": "boolean" }
}
}
Trois étapes, puis l'exécuteur :
- Type.
argsdoit être un object. Si vous avez reçu une chaîne,JSON.parse/json.loadsune seule fois. Échec ou résultat pas un object : renvoyez une erreur. - Contrat.
required,enum,additionalProperties: false. Unextra_cmden trop est un refus. - Nom.
namedoit égaler une clé de votre table de déclarations. Ne devinez pasliving_roomdepuis une « pièce similaire ».
Figez d'abord les formes qui doivent échouer. Elles doivent passer au rouge dans l'espace de travail :
{
"ok": { "room": "living_room", "on": true },
"unknown_room": { "room": "garage", "on": true },
"extra_field": { "room": "living_room", "on": true, "extra_cmd": "unlock" },
"wrong_type": { "room": "living_room", "on": "开" },
"args_as_string": "{\"room\":\"living_room\",\"on\":true}"
}
Seul ok entre dans set_light. garage, extra_cmd, "开", et la charge encore en chaîne : tous renvoient un objet d'erreur. Ne « faites pas au mieux ». Dériver un contrat depuis un échantillon : Comment générer un Schema.
Assembler FunctionResponse
Un retour WebSocket doit s'asseoir dans toolResponse. Chaque FunctionResponse apparie le FunctionCall d'origine par id :
{
"toolResponse": {
"functionResponses": [
{
"id": "call_light_01",
"name": "set_light",
"response": {
"result": {
"room": "living_room",
"on": true,
"applied": true
},
"scheduling": "INTERRUPT"
}
}
]
}
}
Équivalent SDK :
function_response = types.FunctionResponse(
id=fc.id,
name=fc.name,
response={
"result": {"room": "living_room", "on": True, "applied": True},
"scheduling": "INTERRUPT",
},
)
await session.send_tool_response(function_responses=[function_response])
Au retour asynchrone, scheduling dit au modèle comment utiliser ce résultat :
INTERRUPT: couper la parole en cours, dire le résultat tout de suite.WHEN_IDLE: attendre la fin du segment actuel.SILENT: noter d'abord, utiliser plus tard.
Les exemples placent scheduling dans response, à côté de result. C'est un indice d'ordonnancement, pas un champ métier. L'exécuteur ne lit que result. Un échec de validation doit encore renvoyer. Ne restez pas silencieux :
{
"id": "call_light_01",
"name": "set_light",
"response": {
"error": {
"code": "invalid_args",
"message": "room is not in the allowlist"
},
"scheduling": "INTERRUPT"
}
}
L'objet d'erreur porte le même id / name. Le modèle peut alors dire « cette pièce n'est pas sur la liste », au lieu de prétendre que la lumière est allumée. Refermez vous-même le result métier : quels champs, s'il faut un applied, figez-le avec le même Schema. Ne laissez pas scheduling fuiter dans l'exécuteur.
Appels parallèles et annulation
Depuis un prompt, le modèle peut émettre plusieurs functionCalls, puis encore des BidiGenerateContentToolCall. Responsabilité client :
- Parcourir tout le tableau. Ne traitez pas seulement la première entrée.
- Valider chaque
argsà part. Un échec n'empêche pas les autres retours, mais l'entrée en échec doit revenir en erreur. - La longueur du tableau de retour aligne les appels reçus. En oublier un, le modèle n'apparie plus.
{
"toolCall": {
"functionCalls": [
{
"id": "call_light_01",
"name": "set_light",
"args": { "room": "living_room", "on": true }
},
{
"id": "call_light_02",
"name": "set_light",
"args": { "room": "kitchen", "on": false }
}
]
}
}
Quand l'utilisateur interrompt le tour en cours, le serveur peut envoyer toolCallCancellation :
{
"toolCallCancellation": {
"ids": ["call_light_01"]
}
}
Ne plus exécuter un id qui match. Si vous avez déjà tendu la main, la documentation autorise une tentative de rollback. Ce message n'apparaît que lorsque le client interrompt le tour serveur. Ne le fusionnez pas avec un signal audio interrupted ordinaire en un seul « tout annuler ».
Si un outil asynchrone doit lancer une tâche de fond, l'exemple cloud officiel pose la requête lente dans asyncio.create_task et laisse la boucle receive() continuer à écouter. Une fois fini, renvoyer avec l'id d'origine ; scheduling est souvent WHEN_IDLE. Ne bloquez pas cinq secondes dans receive().
Erreurs de parsing courantes
- Traiter toute la trame WebSocket comme
args.argsvit àtoolCall.functionCalls[i].args. Envoyer l'enveloppe à l'exécuteur : le Schema est trop large, ou échoue net. - Ne prendre que
functionCalls[0]. Les lumières en parallèle perdent la seconde pièce. - Inventer un nouvel
idau retour. Live apparie parid. Un nouvel id ne matche plus la main tendue d'origine. - Utiliser un
argschaîne comme un objet. Vérifiez d'abord le type. Une chaîne n'a droit qu'à un parse. - Échouer la validation et ne rien renvoyer. Le modèle attend dans le vide ou parle n'importe comment. Une erreur est encore une
FunctionResponse. - Écrire
schedulingdans le store métier. C'est un indice pour le modèle, pas l'état d'une lumière. - Ignorer
toolCallCancellation. L'utilisateur a déjà interrompu ; la lumière suit encore l'ancienne commande.
Ces erreurs se cachent plus facilement dans une boucle audio : l'utilisateur entend « d'accord », le journal tient un autre id. Passez d'abord les objets au rouge et au vert dans l'espace de travail, puis accrochez-les à une session Live.
Recetter dans la Boîte à outils JSON
- Collez le
toolCallet letoolResponseci-dessus dans le formatage JSON, confirmez que ça parse, et que chaque entrée du tableau a unid. - Extrayez
args. Avec JSON → Schema, générez le contrat cœur, puis ajoutezenum,requiredetadditionalProperties. - Les mêmes champs via Function Calling ; pour réutiliser sur d'autres Hosts, enveloppez encore dans MCP Tool.
- Jetez
garage,extra_cmdet"开"dans la validation Schema, confirmez le refus. Pour aligner une forme de réponse, Structured Output — souvenez-vous : la session Live elle-même n'avale pas ce contrat de réponse.
Tout se traite dans le navigateur, rien n'est envoyé. D'abord un objet capable de refuser un mauvais args et de recopier le même id, ensuite seulement accrocher la fonction à gemini-3.8-live.
FAQ
tool_call du SDK et toolCall du WebSocket, c'est le même objet ?
Même contrat, casse différente. Le WebSocket utilise le camelCase ; le SDK Python le snake_case. Au retour, le SDK appelle send_tool_response ; une trame brute envoie toolResponse.
args est parfois une chaîne, parfois un objet. Que faire ?
Le contrat dit object. Vérifiez d'abord le type. Une chaîne n'a droit qu'à un parse. Échec ou résultat pas un object : renvoyez une erreur. Ne passez pas la chaîne à l'exécuteur.
Que se passe-t-il si le retour oublie l'id ?
Live apparie par id. L'oublier, le recopier faux, ou ne répondre qu'à la moitié : le modèle n'apparie plus cette main tendue. Recopiez aussi name. N'inventez pas un nouvel id.
Si toolCallCancellation arrive, faut-il encore exécuter ?
Non. Arrêtez les ids[] qui matchent. Un effet de bord déjà là peut tenter un rollback. N'apparaît que lorsque le client interrompt le tour serveur.
scheduling doit-il entrer dans le result métier ?
Non. Il est à côté de result, c'est un indice d'ordonnancement pour le modèle. L'exécuteur ne lit que l'objet métier.
En résumé
La boucle d'outils de Gemini 3.8 Live n'exécute rien toute seule. Vous parsez trois objets : toolCall.functionCalls, args, toolResponse.functionResponses. Peu de champs : id, name, un objet JSON. Le danger, c'est la voix qui rend args d'apparence légale. Avant d'exécuter, refusez garage et extra_cmd avec un Schema. Au retour, recopiez le même id. Ne tendez plus la main pour un id annulé. La suite n'est pas d'abord d'ajouter plus d'outils de pièces. C'est d'abord de recetter ces trois objets dans l'espace de travail.