Quelqu'un a posé à l'assistant une question raisonnable :
Fais-moi un rapport de mes tâches de cette semaine, réparties entre En cours et Terminé.
Il a répondu correctement. Il lui a aussi fallu une quarantaine de secondes et une trentaine d'appels d'outils, parce qu'il a listé les tâches, puis ouvert l'historique de chacune, une par une, pour savoir quand la tâche avait bougé et qui l'avait déplacée.
C'est le problème N+1. Tous les ingénieurs backend l'ont croisé dans un ORM : vous chargez une liste, puis lancez une requête par ligne parce que ce dont vous aviez vraiment besoin n'était pas dans la liste. Ici, c'est la même forme, avec un moteur de requêtes plus lent et plus cher, un modèle de langage, à la place de la boucle.
L'agent n'était pas stupide. Mon API ne lui laissait pas d'autre moyen de répondre.
L'agent ne peut demander que ce que vous exposez
Un modèle doté d'outils fait quelque chose de proche de la planification de requêtes. Il lit les descriptions de vos outils, déduit quels appels peuvent produire la réponse, et les exécute. Si l'axe dont il a besoin n'est pas un filtre, il reconstruit cet axe à la main, en récupérant des lignes et en les inspectant.
Mon list_tasks pouvait filtrer par assigné, par statut, par colonne, par étiquette, par n'importe quelle propriété personnalisée et par plage de dates de création. Ce qu'il ne pouvait pas faire, c'était répondre à quand est-ce que ça a été terminé. L'heure de fin existait, dans une colonne appelée resolvedAt, indexée et prête. Simplement, ce n'était pas un filtre, et elle n'apparaissait pas dans la réponse.
Du point de vue du modèle, « qu'est-ce que j'ai terminé cette semaine » se décomposait donc ainsi : lister les tâches fermées, puis ouvrir l'historique de chacune et y chercher le déplacement. Un appel par tâche. L'information était à une clause WHERE de là.
Trois questions à poser sur chaque outil
Depuis, j'ai passé mes outils au crible de ces trois questions. Elles méritent d'être recopiées.
1. L'appelant peut-il filtrer sur chaque axe que les données possèdent ?
Pas « chaque champ », mais chaque axe sur lequel quelqu'un posera une question. Le temps en est presque toujours un, et presque toujours plusieurs : création, mise à jour, fin, dernière modification. Chacun est une question différente. Un outil qui n'expose que createdAt oblige quiconque s'intéresse aux trois autres à parcourir les lignes page par page.
Ma correction tenait en deux paramètres, resolvedFrom et resolvedTo. Quatre lignes de code serveur. Elle a supprimé toute une classe de N+1 de chaque rapport qu'écrit un agent.
2. L'outil sait-il qui l'appelle ?
« Mes tâches » est l'expression la plus courante dans ces demandes, et pendant longtemps l'agent ne pouvait pas y répondre sans deviner. get_current_project renvoyait les membres du projet (noms, rôles, identifiants), mais ne disait jamais lequel d'entre eux ce jeton représente.
Regardez ce qu'un agent en fait. Il examine la liste des membres, voit « Alex Rivera », se souvient que l'utilisateur a dit « mes », et choisit celui dont le nom ressemble le plus à quelque chose qu'il a vu plus tôt. Ça marche jusqu'au jour où une équipe compte deux Alex.
Le serveur renvoie maintenant un objet viewer : l'identifiant, le nom et le rôle du membre à qui appartient le jeton. L'identité ne devrait pas être une déduction.
3. La liste renvoie-t-elle ce que renvoie le détail ?
Si get_task inclut un champ et que list_tasks l'omet, vous avez construit une boucle de récupération. L'agent reçoit vingt lignes, a besoin de ce champ pour les vingt, et appelle l'endpoint de détail vingt fois.
Dans ma version, les éléments de la liste n'avaient ni resolvedAt ni updatedAt, alors que le détail portait les deux. Les ajouter, c'est un changement de deux lignes qui supprime vingt allers-retours.
Le piège : un horodatage qui ment
En ajoutant les filtres, j'ai failli en livrer un quatrième, updatedFrom / updatedTo sur updatedAt, parce que c'est la façon évidente de demander « qu'est-ce qui a changé cette semaine ».
Il aurait été franchement nuisible, et la raison vaut bien au-delà de ce cas.
Dans ma table des tâches, updatedAt est maintenu par l'ORM : n'importe quel UPDATE de la ligne le fait avancer. Mais mes tâches gardent le statut, l'assigné, la priorité, les étiquettes et l'échéance dans une table de valeurs séparée. Donc :
assigner une tâche ne fait pas bouger
updatedAt;changer sa priorité ne le fait pas bouger ;
la déplacer entre deux colonnes ouvertes ne le fait pas bouger ;
un inconnu qui vote pour elle sur votre portail public le fait bouger.
Un filtre construit sur cette colonne aurait répondu à « sur quoi ai-je travaillé cette semaine » par une liste qui excluait le travail et incluait le clic d'un inconnu. Pire, elle aurait eu l'air plausible : un mauvais outil n'échoue pas par une erreur, il échoue par une réponse fausse donnée avec assurance.
Je l'ai laissé de côté, et je l'ai écrit dans la description de l'outil : updatedAt marque le dernier contact avec la ligne, pas la dernière activité. Une description d'outil, c'est de la documentation que le modèle lit vraiment. Servez-vous-en pour exclure des choses, pas seulement pour les expliquer.
Ce que coûte une erreur ici
Pour un agent, un endpoint lent ne donne pas une page lente. Il donne :
Une latence que l'utilisateur ressent. Trente allers-retours séquentiels, chacun attendant qu'un modèle décide du suivant.
Une fenêtre de contexte consumée. Chaque réponse, ce sont des tokens. Trente historiques de tâches peuvent évincer ce que l'utilisateur a réellement demandé.
De l'argent. Ce sont des tokens facturés, chaque fois que quelqu'un pose la question.
Une troncature silencieuse. Un agent à court de place s'arrête tôt et résume ce qu'il a, et sa réponse ne dit pas « je n'ai lu que onze des quarante tâches ».
C'est ce dernier point qui en fait un problème d'exactitude, pas de performance.
Le même rapport, maintenant
Avec les trois corrections en place, le rapport hebdomadaire se résume à :
get_current_project— qui suis-je, et quelles sont les colonnes ;list_tasksavecassigneeId+ la colonne En cours ;list_tasksavecassigneeId+status: "closed"+resolvedFrom.
Trois appels, à plat, quel que soit le nombre de tâches renvoyées. Chaque ligne porte déjà resolvedAt, donc regrouper par jour ne demande rien de plus.
Une réserve que je documente plutôt que de la cacher : resolvedAt enregistre le dernier moment où une tâche est entrée dans une colonne fermée. Reclassez une colonne d'ouverte à fermée, et chaque tâche qu'elle contient reçoit l'horodatage de ce moment, si bien que le rapport d'une semaine peut ramasser du travail en réalité terminé des mois plus tôt. Les agents ne peuvent pas deviner ce genre de réserve. Écrivez-les là où le modèle les lira.
Si vous construisez un serveur MCP
Posez une vraie question à un vrai agent et regardez le journal des appels. Pas le scénario idéal que vous avez conçu, mais la question qu'un utilisateur taperait vraiment, avec ses mots, un vendredi après-midi.
Chaque boucle que vous voyez est un axe que votre API n'exposait pas. La correction est en général une clause WHERE, un champ dans une réponse de liste ou une phrase dans une description. C'est bien moins cher que le contournement du modèle.
Le serveur MCP de BugDump est gratuit sur tous les forfaits, Gratuit compris, et Utiliser BugDump avec Claude Code (MCP) explique comment y brancher Claude Code.
Pour aller plus loin
Ce que j'ai appris en créant un serveur MCP pour un outil de développement — d'autres décisions de conception qui permettent à un agent de bien utiliser les outils du premier coup.
Pourquoi les agents IA ont besoin de jetons personnels sur un serveur MCP — pourquoi chaque agent doit agir au nom d'une personne précise, avec ses droits à elle.
OAuth pour un serveur MCP : Claude et ChatGPT se connectent sans jeton — comment le serveur MCP connecte des apps qui n'ont nulle part où coller un jeton.