Une boucle que vous contrôlez
Le SDK google-genai sait exécuter vos fonctions Python tout seul : vous passez une liste de fonctions, il appelle le modèle, exécute ce qu'il demande, rappelle le modèle, et vous rend la réponse finale. Pour un prototype, c'est parfait. Pour un agent qui modifie un dépôt, c'est inacceptable : entre deux tours, personne ne vérifie la permission, ne prend de point de reprise, ne tronque une sortie de 80 000 caractères ni ne compte les tokens. Le harness commence au moment où vous désactivez cet automatisme.
Le mécanisme
Le function calling fonctionne en deux temps. Vous déclarez des outils (nom, description, schéma JSON des arguments). Le modèle répond soit par du texte, soit par une ou plusieurs parties function_call : il demande, il n'exécute rien. Votre programme exécute, puis renvoie une partie function_response portant le même id. Le modèle ne voit jamais votre code, seulement les déclarations et les résultats.
sequenceDiagram
participant H as Harness (agent.py)
participant M as Gemini
participant O as Outils
H->>M: système + historique + 5 déclarations
M-->>H: function_call run_command("python -m pytest -q")
H->>H: permission, point de reprise, journal
H->>O: exécution dans le bac à sable
O-->>H: sortie (tronquée à 4 000 caractères)
H->>M: function_response (même id)
M-->>H: texte sans appel
H->>H: statut « terminé », métriques
Une trajectoire est la suite complète de ces échanges pour une tâche. Un tour est un aller-retour avec le modèle. Sur la démo de ce projet, la trajectoire réelle compte 13 à 17 tours.
La mise en œuvre
Le point de départ : désactiver l'exécution automatique et garder le client. Un genai.Client() créé à la volée et non référencé se ferme, et l'appel suivant échoue.
self.client = genai.Client(http_options=types.HttpOptions(timeout=timeout_s * 1000))
...
config = types.GenerateContentConfig(
system_instruction=system,
tools=[types.Tool(function_declarations=[
types.FunctionDeclaration(name=t["name"], description=t["description"],
parameters_json_schema=t["parameters"])
for t in tools
])],
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), # c'est NOTRE boucle
)La boucle elle-même (agent.py) vérifie trois conditions d'arrêt avant chaque appel, puis traite la réponse :
while True:
if report.turns >= self.s.max_turns: # 25 par défaut
report.status = "tours épuisés"; break
if report.prompt_tokens + report.output_tokens >= self.s.session_token_cap:
report.status = "budget épuisé"; break
if last_prompt > self.s.compact_ratio * self.s.context_budget:
history, n = context.compact(...) # leçon 5
reply = self.model.generate(SYSTEM, history, DECLARATIONS)
history.append(reply.content) # contenu complet, signatures comprises
calls = [p.function_call for p in reply.content.parts or [] if p.function_call]
if not calls:
report.status, report.final_text = "terminé", text; break
responses = [types.Part(function_response=types.FunctionResponse(
id=c.id, name=c.name, response=self.execute(c.name, dict(c.args or {}), report)))
for c in calls]
history.append(types.Content(role="user", parts=responses))Trois choix méritent d'être défendus.
- On ajoute
reply.contenttel quel, pas une reconstruction. Les modèles Gemini récents attachent une signature de raisonnement (thought_signature) aux appels ; la reconstruire à la main la perd et dégrade les tours suivants. - Les tokens de raisonnement sont comptés en sortie.
usage_metadata.thoughts_token_countest facturé au tarif de sortie : l'oublier sous-estime le coût. - Le modèle est une interface, pas une classe concrète.
Model.generate()etModel.summarize()suffisent, ce qui permet un modèle scénarisé (scripted.py) qui rejoue des appels d'outils sans réseau. Les tests de la boucle tournent donc en 5 secondes, sans clé.
Une dernière garde vient de l'expérience : trois appels identiques consécutifs (même outil, mêmes arguments) arrêtent la session avec le statut « boucle détectée ». Un modèle qui relit le même fichier en boucle consomme des tokens sans rien produire.
Résultat obtenu
$ make test # 2026-10-15
............................ [100%]
28 passed in 3.55s
$ make run # sans clé : scénario hors ligne
Mode hors ligne : modèle scénarisé (aucune clé, aucun appel réseau).
Avant : 4 failed, 2 passed in 0.09s
Après : 6 passed in 0.03s
statut terminé
tours 7
appels d'outils 9Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
400 INVALID_ARGUMENT au deuxième tour | function_response sans l'id de l'appel, ou parties réordonnées | renvoyer id=c.id, une réponse par appel, dans l'ordre |
| L'agent « oublie » son raisonnement entre deux tours | contenu du modèle reconstruit à la main, signature perdue | ajouter reply.content intact à l'historique |
| Facture deux fois plus élevée que prévu | tokens de raisonnement ignorés dans le calcul | candidates_token_count + thoughts_token_count au tarif de sortie |
| Une session tourne 40 minutes | aucune limite de tours ni de tokens | MAX_TURNS, SESSION_TOKEN_CAP, détection de répétition |
client has been closed | client créé dans une fonction et libéré | un client par instance de GeminiModel, conservé |
La règle de décision
Gardez l'exécution automatique du SDK pour les assistants dont les outils sont en lecture seule et sans coût. Dès qu'un outil écrit, exécute ou coûte, écrivez la boucle : c'est 60 lignes, et c'est le seul endroit où les contrôles des leçons suivantes peuvent s'insérer. À surveiller : la distribution du nombre de tours par tâche, et le taux de sessions qui finissent autrement que par « terminé ».
Pratique
Votre agent répond correctement au premier tour, puis l'API renvoie une erreur 400 dès qu'il appelle deux outils dans la même réponse. Que vérifiez-vous en premier ?
Une erreur 400 au tour suivant signale presque toujours un historique mal formé : réponses manquantes, id absents ou réponses éclatées en plusieurs messages. Le quota donnerait un 429, pas un 400. Changer de modèle masque le bug. La température n'a rien à voir avec la validité de l'historique.
Un collègue propose de garder automatic_function_calling activé et d'ajouter les contrôles de permission dans chaque fonction Python exposée. Quel est le défaut principal ?
Une permission par outil reste possible, mais tout ce qui dépend de l'état de la session (tokens cumulés, historique à compacter, appels répétés, journal ordonné) doit vivre entre deux appels au modèle, donc dans votre boucle. Les autres réponses sont fausses : le SDK propage les exceptions et une fonction peut recevoir n'importe quel argument.
Exercice. Ajoutez une condition d'arrêt « durée maximale » (MAX_SECONDS, 300 par défaut) avec le statut « délai dépassé », et un test hors ligne qui la déclenche.
Voir la solution
Dans Settings : max_seconds: int = field(default_factory=lambda: int(_env("MAX_SECONDS", "300"))).
Dans la boucle, avec les autres gardes :
if time.monotonic() - t_start >= self.s.max_seconds:
report.status = "délai dépassé"
breakTest : un ScriptedModel dont generate est enveloppé pour dormir 0,2 s, avec max_seconds=0 via dataclasses.replace. Le statut doit être « délai dépassé » après 0 tour. Ne mettez pas cette garde dans l'outil : un appel au modèle lent (60 s de raisonnement) doit lui aussi compter.
- Je sais désactiver l'exécution automatique des outils et écrire la boucle
- Je sais construire une
function_responsevalide pour plusieurs appels simultanés - Je compte les tokens de raisonnement dans le coût
- Je teste ma boucle sans clé avec un modèle scénarisé