Voyager, l’agent IA qui joue à Minecraft (3/4)

En mai 2023, une équipe de chercheurs a publié un papier pour présenter Voyager, un agent qui s'appuie notamment sur un LLM pour jouer en totale autonomie à Minecraft. A l'époque marginale, cette approche qui consiste à exploiter le potentiel de l'IA générative en faisant d'un modèle une brique parmi d'autres d'un système est désormais devenue centrale.
Voyager, l'agent IA qui joue à Minecraft
Quelque part dans Minecraft, l'aventure continue...
Dans ces conditions, la relecture attentive du papier et du code mis à disposition à l'époque permet de comprendre comment s'y prendre pour ne pas être dépassé par les développements d'une ingénierie très particulière, visiblement promise à un bel avenir depuis que la croissance des scaling laws semble ralentir, et qu'en conséquence l'industrie cherche à cueillir les low-hanging fruits autrement que par le prompt engineering.
C'est le troisième article d'une série. Dans le premier article, Voyager a été présenté dans son principe, de même que les technologies sur lesquelles il s'appuie, et la lecture du code a commencé pour étudier la manière dont Voyager collecte des informations sur l'environnement dans lequel il contrôle le bot – le personnage dans Minecraft. Dans le second article, il a été question de la manière dont Voyager utilise un LLM pour générer la prochaine tâche à faire accomplir par le bot. Désormais, il est possible d'étudier comment cet agent utilise un LLM pour générer le code JavaScript qui permet d'essayer de faire accomplir cette tâche par le bot, puis d'évaluer l'accomplissement de la tâche, et enfin capitaliser les compétences acquises à cette occasion.
NB : Ce billet a été rédigé début décembre par un humain et non une boîte de conserve, et sera publié dans un prochain numéro de Programmez!.

Où l'on déboucle la boucle de la boucle

Disposant de l'énoncé d'une tâche et de son contexte, qui n'est autre que l'exposé de la manière dont le bot doit s'y prendre pour l'accomplir, Voyager.learn () appelle Voyager.rollout () en les lui passant.
A ce stade, il convient de préparer le lecteur à ce qui l'attend, et rien de mieux encore qu'un schéma qui contient un bout de code pour lui faire comprendre. Ce schéma prolonge celui qui présente la boucle principale, présenté beaucoup plus tôt, en détaillant la boucle qu'elle contient :
Le déroulement d'une étape de la boucle principale
Le déroulement d'une étape de la boucle principale.
Voyager.rollout () commence par appeler Voyager.reset () en lui relayant l'énoncé de la tâche et son contexte. Cette dernière, qu'il ne faut pas confondre avec VoyagerEnv.reset () que Voyager.learn () a déjà appelée avant de se lancer dans la boucle, appelle de nouveau VoyagerEnv.reset () en lui passant comme précédemment un dictionnaire options.
Il n'avait pas été question de options jusqu'alors, car ce n'était pas le moment. Toutefois, le changement, c'est maintenant. Ce dictionnaire contient deux entrées, dont les valeurs conditionnent des aspects essentiels du contrôle du bot :
  • La première entrée est "mode", qui peut valoir "soft" ou "hard". Pour comprendre ce que cela implique, il faut se reporter au gestionnaire de la requête /start sur le serveur HTTP, car options est l'unique paramètre joint à cette requête. Là, il apparaît que le mode "hard" porte visiblement mal son nom, car dans ce mode, le gestionnaire dote le bot de tous les objets qui figurent dans le dictionnaire options["inventory"] dans les quantités indiquées – une entrée est au format <nom>: <nombre> –, et par ailleurs l'équipe avec tous les objets qui figurent dans le tableau options["equipment"], sauf la main droite.
  • La seconde entrée de options est "wait_ticks", qui par défaut vaut 20. L'on a déjà mentionné comment le gestionnaire de la requête /step donne du temps au bot pour se balader afin d'alimenter l'observation finalement retournée. C'est donc sur ce nombre de ticks que le gestionnaire se base en cette occasion, comme dans d'autres d'ailleurs. Pour rappel, un tick est événement de Minecraft qui survient toutes les 50 ms.
Mais le mode par défaut est "soft", et l'on s'y tiendra donc.
A ne pas confondre donc avec le niveau de difficulté dans Minecraft, que justement Voyager.reset () spécifie juste après :
difficulty = (
	"easy" if len(self.curriculum_agent.completed_tasks) > 15 else "peaceful"
)
Eh oui ! Tant que le bot n'a pas réussi au moins 15 tâches, il évolue dans un monde très cool, car en difficulté Peaceful. Une fois qu'il a fait ses preuves, le monde devient plus cruel, car la difficulté passe à Easy. Pour le lecteur qui l'ignore, les niveaux de difficulté dans Minecraft sont Peaceful, Easy, Normal, Hard et Hardcore. En mode Peaceful, les monstres disparaissent aussitôt qu'ils apparaissent, sinon quelques horreurs perdues au fond dans le Nether – le monde parallèle où le bot ne met vraisemblablement jamais les pieds –, et que par ailleurs le joueur récupère plus rapidement des points de vie, et n'a jamais faim. En mode Easy, les monstres causent moitié moins de dommages qu'en mode Normal. Bref, le bot n'a pas à craindre d'être défoncé par un streum ou de crever la dalle tant qu'il n'a pas accompli 15 tâches.
Ensuite, Voyager.reset () appelle VoyagerEnv.step () :
# step to peek an observation
events = self.env.step(
	"bot.chat(`/time set ${getNextTime()}`);\n"
	+ f"bot.chat('/difficulty {difficulty}');"
)
Cette méthode a déjà été appelée par Voyager.learn () avant de rentrer dans la boucle principale, mais contrairement à ici, c'était en lui passant une chaîne vide. Comme il est possible de le constater, elle contient ici deux lignes de code JavaScript. Ces dernières donnent facilement à voir comment le serveur Python peut contrôler le bot via Mineflayer, puisqu'il s'agit d'appels à la méthode .chat () du bot créé avec Mineflayer, ce qui se traduit par la saisie automatique de commandes dans l'interface de Minecraft. En effet, pour le lecteur qui l'ignore, Minecraft comprend une interface qui permet de contrôler tout ou presque dans le jeu via une bonne centaines de commandes. L'on rentrera plus loin dans les détails, quand il sera question de passer à VoyagerEnv.step () du code JavaScript généré.
Voyager.reset () appelle ensuite SkillManager.retrieve_skills (). Après Curriculum et Action, c'est le troisième agent que l'on croise. Comme le lecteur s'en doute, tout comme la classe CurriculumAgent s'appuie sur une base de données vectorielle créée par son constructeur pour stocker les questions, la classe SkillManager s'appuie sur une telle base, elle aussi créée par son constructeur, pour stocker les compétences, et pour sa part sauvegardée dans /ckpt/skill/vectordb/.
Une compétence, c'est quoi ? Pour le savoir, il faut rompre un instant avec le fil chronologique des explications pour jeter un œil sur SkillManager.add_new_skill (), méthode qui ne sera éventuellement appelée que bien plus tard. Elle ajoute une entrée à la base des compétences de cette manière :
self.vectordb.add_texts(
	texts=[skill_description],
	ids=[program_name],
	metadatas=[{"name": program_name}],
)
L'exploration du code révèle qu'une compétence, c'est trois choses :
  • une description en pas plus de six phrases sur une seule ligne de texte, dont l'on verra qu'elle... est générée par le LLM à partir du code JavaScript... que le LLM a généré pour accomplir l'action dont... il a généré l'énoncé et le contexte – ie : l'énoncé du moyen de l'accomplir ;
  • un identifiant qui est le nom d'une fonction asynchrone qui ne prend que bot pour paramètre... générée par le LLM et qui appelle au besoin d'autres fonctions de ce type... générées donc par le LLM, et qui correspond donc au code JavaScript requis pour accomplir l'action... dont le LLM a généré l'énoncé et le contexte ;
  • une métadonnée qui n'est que "name", dont la valeur reprend le nom de la fonction.
Pour revenir à Voyager.reset (), quand elle appelle SkillManager.retrieve_skills (), c'est de cette manière :
skills = self.skill_manager.retrieve_skills(query=self.context)
Le paramètre qu'elle lui passe n'est autre que le contexte de la tâche courante – pour rappel, la manière d'accomplir la tâche, comme "You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs." –, que Voyager.reset () a mémorisé dans une propriété de Voyager après l'avoir reçu de Voyager.rollout (). Sur cette base, SkillManager.retrieve_skills () peut effectuer une recherche par similitude dans la base de données vectorielle des compétences, et retourner un tableau des compétences sous forme d'entrées <programme>: { code: <code>, description: <description> }, où <programme> est le nom de la fonction.
A ce stade, le lecteur attentif se demandera comment SkillManager.retrieve_skills () peut retrouver le code JavaScript de la compétence s'il ne figure pas dans la base. De fait, il réside ailleurs. Tout comme CurriculumAgent stocke les réponses aux questions dans un cache, qui n'est qu'un dictionnaire d'entrées <question>: <réponse>, dont au passage l'on peut rajouter qu'il est sauvegardé dans un fichier /ckpt/curriculum/qa_cache.json que le constructeur de la classe tente de recharger, SkillManager stocke le code JavaScript des compétences dans un dictionnaire d'entrées <programme>: { "code": <code>, "description": <description> }, sauvegardé dans un fichier /ckpt/skill/skills.json – de plus, le code et sa description sont sauvegardés séparément dans /skill/code/<programme>.js et /skill/description/<programme>.js, mais ces fichiers ne servent visiblement qu'à déboguer.
D'ailleurs, l'archive de Voyager contient les résultats de trois essais. Ainsi, dans le répertoire /skill_library/trial1/skill/, l'on trouve un fichier skills.json, dont une entrée – ici remise en forme en remplaçant les "\n" par de vrais retours à la ligne – permet de constater très concrètement à quoi peut correspondre une compétence :
{
	"mineWoodLog": {
		"code": "async function mineWoodLog(bot) {
		  const woodLogNames = [\"oak_log\", \"birch_log\", \"spruce_log\", \"jungle_log\", \"acacia_log\", \"dark_oak_log\", \"mangrove_log\"];

		  // Find a wood log block
		  const woodLogBlock = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
			return bot.findBlock({
			  matching: block => woodLogNames.includes(block.name),
			  maxDistance: 32
			});
		  });
		  if (!woodLogBlock) {
			bot.chat(\"Could not find a wood log.\");
			return;
		  }

		  // Mine the wood log block
		  await mineBlock(bot, woodLogBlock.name, 1);
		  bot.chat(\"Wood log mined.\");
		}",
		"description": "async function mineWoodLog(bot) {
			// The function is about mining a single wood log block. It searches for a wood log block by exploring the environment until it finds one of the seven types of wood logs. If a wood log block is found, it is mined and a success message is sent. If no wood log block is found, a failure message is sent.
		}"
}
Les compétences chargées, Voyager.reset () s'en remet à l'agent Action pour constituer les prompts système et utilisateur qui vont permettre de demander au LLM de générer ce que le lecteur attend depuis le début, à savoir le code JavaScript dont il vient d'être question pour que le bot accomplisse la tâche selon le moyen décrit dans son contexte en mobilisant au besoin les compétences déjà acquises.

GPT pisse du code aussi

Comme toujours, ActionAgent comporte deux méthodes, l'une pour constituer le prompt système et l'autre, le prompt utilisateur.
Pour ce qui concerne le prompt système, ActionAgent.render_system_message () innove un peu, car contrairement aux méthodes similaires de CurriculumAgent, elle ne se contente pas de retourner tel quel le contenu d'un fichier texte, mais l'enrichit avant. Ce fichier, c'est /prompts/action_template.txt. Le prompt est assez long, et l'on en détaillera la structure en s'appuyant sur de larges extraits – le lecteur est renvoyé au fichier pour consulter le prompt dans son intégralité :
  • il donne un rôle au LLM :
    You are a helpful assistant that writes Mineflayer javascript code to complete any Minecraft task specified by me.
    
  • il lui fournit le code JavaScript des fonctions des compétences trouvées dans la base de données vectorielle – code donc tiré du fichier skill.json –, et aussi de fichiers qui correspondent à des compétences codées en dur, dont il sera question plus loin :
    Here are some useful programs written with Mineflayer APIs.
    
    {programs}
    
  • il lui décrit les informations qu'il va lui fournir, c'est-à-dire le code et les états du bot et de son environnement, mais aussi des informations relatives à la tâche à accomplir, ainsi qu'au code JavaScript qui a été utilisé sans succès lors d'une itération précédente pour tenter d'accomplir la tache en question, sous la forme d'un rapport d'erreurs JavaScript rencontrées lors de l'exécution de ce code, de la liste des messages produits par Minecraft lors de cette exécution, et du résultat d'une évaluation de la réussite ou de l'échec de l'accomplissement de la tâche par le bot au moyen de cette exécution, toutes choses dont il sera question plus loin (extrait) :
    At each round of conversation, I will give you
    Code from the last round: ...
    Execution error: ...
    Chat log: ...
    Biome: ...
    Time: ...
    <...>
    Task: ...
    Context: ...
    Critique: ...
    
  • il lui donne des règles à respecter :
    You should then respond to me with
    Explain (if applicable): Are there any steps missing in your plan? Why does the code not complete the task? What does the chat log and execution error imply?
    Plan: How to complete the task step by step. You should pay attention to Inventory since it tells what you have. The task completeness check is also based on your final inventory.
    Code:
        1) Write an async function taking the bot as the only argument.
        2) Reuse the above useful programs as much as possible.
            - Use `mineBlock(bot, name, count)` to collect blocks. Do not use `bot.dig` directly.
            - Use `craftItem(bot, name, count)` to craft items. Do not use `bot.craft` or `bot.recipesFor` directly.
            - Use `smeltItem(bot, name count)` to smelt items. Do not use `bot.openFurnace` directly.
            - Use `placeItem(bot, name, position)` to place blocks. Do not use `bot.placeBlock` directly.
            - Use `killMob(bot, name, timeout)` to kill mobs. Do not use `bot.attack` directly.
        3) Your function will be reused for building more complex functions. Therefore, you should make it generic and reusable. You should not make strong assumption about the inventory (as it may be changed at a later time), and therefore you should always check whether you have the required items before using them. If not, you should first collect the required items and reuse the above useful programs.
        4) Functions in the "Code from the last round" section will not be saved or executed. Do not reuse functions listed there.
        5) Anything defined outside a function will be ignored, define all your variables inside your functions.
        6) Call `bot.chat` to show the intermediate progress.
        7) Use `exploreUntil(bot, direction, maxDistance, callback)` when you cannot find something. You should frequently call this before mining blocks or killing mobs. You should select a direction at random every time instead of constantly using (1, 0, 1).
        8) `maxDistance` should always be 32 for `bot.findBlocks` and `bot.findBlock`. Do not cheat.
        9) Do not write infinite loops or recursive functions.
        10) Do not use `bot.on` or `bot.once` to register event listeners. You definitely do not need them.
        11) Name your function in a meaningful way (can infer the task from the name).
    
  • il lui indique quel format sa réponse doit respecter :
    You should only respond in the format as described below:
    RESPONSE FORMAT:
    {response_format}
    
Partant de ce modèle, ActionAgent.render_system_message () procède à deux substitutions pour constituer le prompt final.
En premier lieu, {programs} est remplacé par la concaténation de codes :
# FIXME: Hardcoded control_primitives
base_skills = [
	"exploreUntil",
	"mineBlock",
	"craftItem",
	"placeItem",
	"smeltItem",
	"killMob",
]
if not self.llm.model_name == "gpt-3.5-turbo":
	base_skills += [
		"useChest",
		"mineflayer",
	]
programs = "\n\n".join(load_control_primitives_context(base_skills) + skills)
Autrement dit :
  • le code JavaScript des compétences tirées de la base de données vectorielles, qui vient d'être interrogée pour récupérer les compétences en rapport avec le contexte de la tâche – la manière de s'y prendre pour réaliser la tâche, comme "You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs." ;
  • le code JavaScript tiré de différents fichiers, la fonction load_control_primitives_context () ne faisant que retourner le contenu des fichiers d'extension ".js" qui figurent dans le répertoire /control_primitives_context/ dont les noms lui sont passés (/control_primitives_context/__init__.py).
Le dernier point est remarquable à deux titres. Tout d'abord, il montre que Voyager ne part pas de rien, puis qu'il s'appuie sur bon nombre de compétences prédéfinies, qui sont aussi fondamentales que tuer un monstre ou fabriquer un objet. Ensuite, il montre que la liste de ces compétences dépend du LLM utilisé, puisqu'elle doit être enrichie dans le cas de GPT-3.5 turbo par rapport à GPT-4.
Au passage, c'est l'occasion de jeter un œil sur le code JavaScript qu'il est demandé au LLM de "Reuse [...] as much as possible". Par exemple, killMob.js, qui permet de lancer le bot à l'assaut d'une créature :
// Kill a pig and collect the dropped item: killMob(bot, "pig", 300);
async function killMob(bot, mobName, timeout = 300) {
    const entity = bot.nearestEntity(
        (entity) =>
            entity.name === mobName &&
            entity.position.distanceTo(bot.entity.position) < 32
    );
    await bot.pvp.attack(entity);
    await bot.pathfinder.goto(
        new GoalBlock(entity.position.x, entity.position.y, entity.position.z)
    );
}
Mais comment le LLM peut-il comprendre à quoi sert ce code JavaScript ? C'est tout l'objet de ligne de commentaire qui précède la fonction. En plus de donner l'objet de la fonction, elle explique comment l'appeler. Il s'en trouve avant chaque fonction, dans chacun des fichiers. L'on relèvera donc, non sans ironie, que l'un des intérêts pour un développeur de recourir à un LLM pour générer du code qui appelle une API, c'est de le forcer à documenter l'API en question. De fait, cette partie du code de Voyager contient plus de commentaires à l'attention du LLM que le reste du code de Voyager à l'attention du lecteur !
Et le fichier mineflayer.js ? Il contient une petite vingtaine d'instructions qui permettent d'instancier des objets ou d'appeler des fonctions de Mineflayer, un commentaire précisant chaque fois quelle en est l'utilité. Par exemple :
await bot.pathfinder.goto(goal); // A very useful function. This function may change your main-hand equipment.
// Following are some Goals you can use:
new GoalNear(x, y, z, range); // Move the bot to a block within the specified range of the specified block. `x`, `y`, `z`, and `range` are `number`
Retour à la génération du prompt système. En plus de remplacer {programs} par le code JavaScript des compétences, ActionAgent.render_system_message () remplace {response_format} par le contenu du fichier texte /prompts/action_response_format.txt :
Explain: ...
Plan:
1) ...
2) ...
3) ...
...
Code:
```javascript
// helper functions (only if needed, try to avoid them)
...
// main function after the helper functions
async function yourMainFunctionName(bot) {
  // ...
}
```
Comme il est possible de le constater, il est attendu du LLM qu'il produise un plan en trois étapes. Le lecteur peut se demander à quoi bon : ne suffirait-il pas qu'il génère le code JavaScript pour accomplir la tâche ? C'est qu'à l'époque de Voyager, les recherches entreprises pour faire générer une meilleure réponse à une question par un LLM avaient notamment déterminé qu'il valait mieux demander au LLM de procéder ainsi : c'est la fameuse technique Chain-of-Tought, dite CoT pour les intimes. Initialement cantonnée au prompting, la technique a récemment été reprise pour le pre-training par OpenAI, qui en a fait la pierre angulaire de o1, son dernier LLM en date annoncé en fanfare en septembre dernier. Toutefois, si OpenAI clame sur tous les toits que son LLM ainsi "raisonne", il faut pointer ici des travaux qui tendent à montrer que c'est moins un raisonnement que des tokens qui font la différence, ou , que lorsqu'un LLM est invité à produire son raisonnement, il peut affabuler. Mais bon, comme un autre Sam avant lui, que ne raconterait pas Sam Altman...
Autant pour le prompt système. Pour ce qui concerne le prompt utilisateur, ActionAgent.render_human_message () reçoit en paramètres les observations – pour rappel, le tableau bot.cumulativeObs retourné par le serveur HTTP suite à l'appel à VoyagerEnv.step (), appelée pour "peek an observation" –, l'énoncé de la tâche et son contexte. La méthode peut prendre d'autres paramètres, mais il n'en sera question que plus loin.
ActionAgent.render_human_message () génère donc le prompt qui contient tout ce qui a été promis au LLM dans le prompt système, grosso modo en se livrant à une série de concaténations dans le détail desquelles il n'est pas utile de se plonger maintenant, sinon pour mentionner ce passage assez particulier :
if not (
	task == "Place and deposit useless items into a chest"
	or task.startswith("Deposit useless items into the chest at")
):
	observation += self.render_chest_observation()
Ainsi, la liste des coffres alentour sous forme de texte, dont il a déjà été question, ne figure dans le prompt utilisateur que si la tâche n'est pas de déposer des objets dans un coffre. Pourquoi ? Aucun commentaire ne vient l'expliquer...
L'on reviendra sur ce prompt utilisateur quand il sera question de tenir compte de l'évaluation du code JavaScript généré, c'est-à-dire des erreurs JavaScript, des erreurs Minecraft, et du résultat d'une évaluation de la réussite ou de l'échec de la tâche que ce code est censé faire accomplir au bot.
Disposant des prompts système et utilisateur, Voyager.reset () les stocke dans Voyager.messages et rend la main à Voyager.rollout (), non sans avoir au passage purgé une liste, Voyager.conversations, dont il sera aussi question plus loin.

Un paquet de compétences

Voyager.rollout () reprend donc la main, pour se lancer dans la boucle qui est vraiment au cœur de Voyager, et dont il ne sort que lorsque Voyager.step () retourne un drapeau done à true :
while True:
	messages, reward, done, info = self.step()
	if done:
		break
Voyager.step () utilise les prompts précédemment générés pour interroger le LLM, et rajoute ces prompts ainsi que la réponse sous forme d'un tuple dans la liste Voyager.conversations dont il a été question à l'instant, ce qui renseigne sur le contenu de cette dernière. Puis, elle appelle ActionAgent.process_ai_message ().
Comme le lecteur s'en doute, le code JavaScript que contient la réponse doit être extrait de cette dernière, puis vérifié à trois niveaux : il faut qu'il puisse être exécuté, qu'il commande au bot d'accomplir des actions autorisées dans Minecraft, et qu'il permette d'accomplir la tâche.
ActionAgent.process_ai_message () procède à la première vérification en s'appuyant sur Babel, un module de Node.js qui intègre un parser capable de produire le modèle objet d'un code JavaScript, pour autant que ce dernier soit valide. Un module de Node.js utilisé par un programme en Python ? Oui, c'est possible par le truchement de JSPyBridge, et si c'est encore expérimental, du moins est-ce simple :
from javascript import require

babel = require("@babel/core")
#...
parsed = babel.parse(code)
Après s'être ainsi assurée de la validité du code JavaScript généré par le LLM, la méthode parcourt la liste de fonctions qu'il comporte dressée par Babel, de la dernière à la première, à la recherche d'une fonction asynchrone qui ne prend que bot en paramètre. S'il la trouve, ActionAgent.process_ai_message () retourne un dictionnaire qui contient trois entrées :
Entrée Valeur
program_code La concaténation des lignes de code qui constituent le corps de la fonction, séparées par deux retours à la ligne.
program_name Le nom de la fonction.
exec_code
La ligne de code JavaScript qui permet d'exécuter la fonction, c'est-à-dire :
"await (<fonction>) (bot);"
En cas d'erreur dans le code JavaScript, ou si la fonction est introuvable, ActionAgent.process_ai_message () retourne une chaîne qui détaille l'erreur, mais pas avant de s'y être reprise jusqu'à 3 fois. Toutefois, la méthode ne change strictement rien d'une itération à l'autre, sinon en s'accordant une pause d'une seconde via un time.sleep(1) parfaitement neutre, ce qui est intriguant.
En se basant sur le type du résultat que ActionAgent.process_ai_message () lui retourne, Voyager.step () détermine si le code JavaScript est valide ou non.
Si ce code est valide, elle appelle VoyagerEnv.step () en lui passant le résultat de la concaténation des entrées "program_code" et "exec_code" du dictionnaire, séparées par un retour à la ligne – autrement dit le code JavaScript généré suivi d'une ligne de code qui en appelle la dernière des fonctions asynchrones qui ne prennent que bot en paramètre –, et SkillManager.programs.
SkillManager.programs est une propriété, au sens Python du terme :
def programs(self):
	programs = ""
	for skill_name, entry in self.skills.items():
		programs += f"{entry['code']}\n\n"
	for primitives in self.control_primitives:
		programs += f"{primitives}\n\n"
	return programs
Précédemment, l'on a vu que SkillManager.add_new_skill () stocke la description d'une nouvelle compétence dans une base de données vectorielle, et son code JavaScript dans /ckpt/skill/skills.json. Ce que l'on n'a pas vu, c'est qu'elle ajoute aussi la compétence à un dictionnaire SkillManager.skills :
self.skills[program_name] = {
	"code": program_code,
	"description": skill_description,
}
Par ailleurs, l'on n'a pas vu que le constructeur de SkillManager appelle load_control_primitives () (/control_primitives/__init__.py) :
self.control_primitives = load_control_primitives()
Tout comme load_control_primitives_context () croisée plus tôt, cette fonction retourne un tableau du contenu de fichiers d'extension ".js" qui se trouvent dans un répertoire. Sauf qu'il ne s'agit pas des fichiers du répertoire /control_primitives_context/, mais du répertoire /control_primitives/, et qu'il ne s'agit pas de certains selon la version de GPT utilisée, mais systématiquement de tous. En fait, ces fichiers n'ont rien à voir, ceux du répertoire /control_primitives/ étant plus nombreux, et quand ils sont homonymes, nettement plus gros :
/control_primitives_context/ /control_primitives/
Fichier Taille Fichier Taille
craftHelper.js 2 255
craftItem.js 608 craftItem.js 1 492
exploreUntil.js 1 110 exploreUntil.js 2 617
givePlacedItemBack.js 1 459
killMob.js 446 killMob.js 1 671
mineBlock.js 483 mineBlock.js 1 153
mineflayer.js 2 523
placeItem.js 1 127 placeItem.js 2 681
shoot.js 896
smeltItem.js 913 smeltItem.js 2 481
useChest.js 1 873 useChest.js 4 216
waitForMobRemoved.js 2 743
De fait, dans le cas de fichiers homonymes, le code JavaScript qu'ils contiennent est plus sophistiqué. Pour reprendre l'exemple de killMob.js, sa version qui figure dans /control_primitives_context/, reproduite plus tôt, ne comprend qu'une grosse douzaine de lignes, alors que sa version qui figure dans /control_primitives/ est nettement plus longue :
async function killMob(bot, mobName, timeout = 300) {
    // return if mobName is not string
    if (typeof mobName !== "string") {
        throw new Error(`mobName for killMob must be a string`);
    }
    // return if timeout is not number
    if (typeof timeout !== "number") {
        throw new Error(`timeout for killMob must be a number`);
    }

    const weaponsForShooting = [
        "bow",
        "crossbow",
        "snowball",
        "ender_pearl",
        "egg",
        "splash_potion",
        "trident",
    ];
    const mainHandItem = bot.inventory.slots[bot.getEquipmentDestSlot("hand")];

    const entity = bot.nearestEntity(
        (entity) =>
            entity.name === mobName &&
            // kill mob distance should be slightly bigger than explore distance
            entity.position.distanceTo(bot.entity.position) < 48
    );
    if (!entity) {
        bot.chat(`No ${mobName} nearby, please explore first`);
        _killMobFailCount++;
        if (_killMobFailCount > 10) {
            throw new Error(
                `killMob failed too many times, make sure you explore before calling killMob`
            );
        }
        return;
    }

    let droppedItem;
    if (mainHandItem && weaponsForShooting.includes(mainHandItem.name)) {
        bot.hawkEye.autoAttack(entity, mainHandItem.name);
        droppedItem = await waitForMobShot(bot, entity, timeout);
    } else {
        await bot.pvp.attack(entity);
        droppedItem = await waitForMobRemoved(bot, entity, timeout);
    }
    if (droppedItem) {
        await bot.collectBlock.collect(droppedItem, { ignoreNoPath: true });
    }
    bot.save(`${mobName}_killed`);
}
Pourquoi le code transmis à Voyager.step () est-il notamment tiré de tous les fichiers de /control_primitives/, alors que dans le prompt système, le code qu'il est demandé au LLM de réutiliser autant que possible est tiré de fichiers de /control_primitives_context/, lesquels sont moins nombreux, pas tous repris selon la version de GPT, et contiennent un code moins sophistiqué ?
La lecture comparée de plusieurs couples des fichiers homonymes laisse entendre que dans le fond, les codes ne sont pas différents : chaque fois, c'est la même fonction qui accomplit la même chose, sinon que celle qui figure dans le fichier de /control_primitives/ comprend des contrôles d'erreur et se termine par un appel à bot.save () en lui passant une chaîne qui décrit ce qui vient de se passer – "cow_killed", "gold_mined", etc. –, une fonction qui se contente d'appeler bot.emit () pour rajouter à bot.cumulativeObs une observation de type "onSave" qui contient une entrée "onSave" dont la valeur correspond à la chaîne passée à bot.save ().
Toutefois, ces précisions ne suffisent pas pour comprendre. La présence d'un commentaire # FIXME: Hardcoded control_primitives dans ActionAgent.render_system_message () fait que tout cela reste intriguant...
Qui qu'il en soit, c'est donc le grand retour dans VoyagerEnv.step (), que l'on croise pour la troisième fois, sauf que cette fois, la méthode reçoit du code JavaScript en paramètre :
  • code, le code JavaScript généré par le LLM pour accomplir la tâche, suivi de la ligne de code qui appelle la dernière des fonctions asynchrones qui ne prennent que bot en paramètre, censée accomplir ce miracle ;
  • programs, le code JavaScript des compétences que ce code appelle éventuellement, récupéré après avoir interrogé la base de données vectorielle sur les compétences pertinentes au regard du contexte de la tâche – la manière de l'accomplir –, et le code JavaScript des compétences prédéfinies tiré des fichiers d'extension ".js" de /control_primitives/.

Enfin, de l'action !

L'on ne présente donc plus VoyagerEnv.step (), qui s'assure que toute l'infrastructure de Voyager est en place avant de simplement soumettre une requête /step au serveur HTTP, en lui relayant cette fois-ci le code précédemment décrit, sous la forme d'un dictionnaire Python / objet JavaScript :
data = {
	"code": code,
	"programs": programs,
}
Et l'on ne présente donc plus le gestionnaire de la requête /step sur le serveur HTTP, sinon désormais pour en venir à cette ligne... :
const r = await evaluateCode(code, programs);
...qui conduit à ces lignes dans evaluateCode () :
try {
	await eval("(async () => {" + programs + "\n" + code + "})()");
	return "success";
} catch (err) {
	return err;
}
C'est ainsi que le code généré par le LLM est finalement exécuté. En cas d'erreur, le gestionnaire de /step émet un événement "error", ce qui se traduit, comme longuement expliqué, par l'ajout d'une observation au tableau bot.cumulativeObs au titre d'un événement "onError", laquelle observation comprend notamment le message d'erreur.
Avant de se terminer, le gestionnaire appelle returnItems (). Le détail de cette fonction a déjà été donné, et il suffira donc de rappeler qu'elle donne au bot : un établi et un four au bot si elle en trouve alentour ; un coffre si son inventaire est près de saturer et qu'il n'en comprend pas ; une pioche en fer si un drapeau bot.iron_pickaxe est positionné et qu'ici encore, son inventaire n'en comprend pas – mais rien dans le code JavaScript de Voyager n'indique que ce drapeau, par défaut à false, puisse jamais passer à true.
Pour finir, comme déjà vu, le gestionnaire de /step du serveur HTTP retourne bot.cumulativeObs au client Python en réponse à la requête. Ce dernier doit maintenant déterminer si l'exécution du code généré par le LLM a effectivement permis d'accomplir la tâche.

La critique des critiques

Voyager.step () reprend donc la main, et après avoir mis à jour la liste des coffres alentour à partir de la dernière observation retournée – ce qui déjà été vu, et l'on n'y revient donc pas –, appelle CriticAgent.check_task_success (). C'est le dernier agent de la bande des quatre, et ce n'est que maintenant que du code généré par le LLM a été exécuté qu'il est possible de chercher à comprendre à quoi il sert.
Voyager.step () passe à CriticAgent.check_task_success () la liste des observations, l'énoncé de la tâche qui devait être accomplie, le contexte de cette dernière, et la liste des coffres alentour tirée de la dernière observation en date, mise sous forme de texte. Tous ces usual suspects ont déjà été présentés, et l'on n'y revient donc pas. Elle rajoute un compteur, max_retries, qui vaut 5.
Comme tous les agents, CriticAgent constitue des prompts système et utilisateur via des méthodes dédiées.
Pour ce qui concerne le prompt système, comme pour tous les agents – ou presque, car l'on a vu que ActionAgent faisait un peu exception –, ce n'est que la reprise du contenu d'un fichier texte, en l'espèce /prompts/crictic.txt. Le prompt est assez long, et l'on en détaillera la structure en s'appuyant sur de larges extraits – le lecteur est renvoyé au fichier pour consulter le prompt dans son intégralité :
  • il donne un rôle au LLM :
    You are an assistant that assesses my progress of playing Minecraft and provides useful guidance.
    
    You are required to evaluate if I have met the task requirements. Exceeding the task requirements is also considered a success while failing to meet them requires you to provide critique to help me improve.
    
  • il lui décrit les informations qu'il va lui fournir, c'est-à-dire les états du bot et de son environnement, ainsi que l'énoncé de la tâche et son contexte (extrait) :
    Biome: The biome after the task execution.
    Time: The current time.
    <...>
    Task: The objective I need to accomplish.
    Context: The context of the task.
    
  • il lui indique quel format sa réponse doit respecter :
    You should only respond in JSON format as described below:
    {
    	"reasoning": "reasoning",
    	"success": boolean,
    	"critique": "critique",
    }
    Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
    
  • il lui donne un série d'exemples (extrait) :
    Here are some examples:
    INPUT:
    Inventory (2/36): {'oak_log':2, 'spruce_log':2}
    
    Task: Mine 3 wood logs
    
    RESPONSE:
    {
    	"reasoning": "You need to mine 3 wood logs. You have 2 oak logs and 2 spruce logs, which add up to 4 wood logs.",
    	"success": true,
    	"critique": ""
    }
    <...>
    
Pour ce qui concerne le prompt utilisateur, CriticAgent.render_human_message () procède comme ActionAgent.render_human_message () en constituant un prompt de toutes pièces à partir de la liste des observations, de l'énoncé de la tâche, de son contexte, et de la liste des coffres alentour. Au final, ce prompt peut ressembler à cela :
Biome: sparse jungle

Time: sunrise

Nearby blocks: dirt, andesite, cobblestone

Health: 4.0/20

Hunger: 6.0/20

Position: x=1.0, y=143.0, z=45.0

Equipment: ['iron_helmet', 'diamond_chestplate', 'iron_leggings', 'iron_boots', 'iron_sword', 'torch']

Inventory (2/36): {'golden_apple': 2, 'diamond': 12}

Chests:
(1.1, -12.0, 7.2): {'iron_axe': 2, 'apple': 1, 'tnt': 4}
(-6.0, 12.3, 4.2): Unknown items inside
(3.5, 5.1, -2.0): Empty

Task: Craft 1 chest

Context: Craft 1 chest with 8 planks of any kind of wood.
Disposant de ces prompts, CriticAgent.check_task_success () appelle CriticAgent.ai_check_task_success () qui les utilise pour interroger le LLM et obtenir une réponse au format spécifié. Si le LLM ne parvient pas à produire une réponse après max_retries tentatives – par défaut, 5 –, ou s'il retourne False pour "success", la méthode considère que le bot n'a pas réussi la tâche. Dans tous les cas, elle retourne la valeur de "success" ainsi que celle de "critic", à défaut une chaîne vide.
Comme CurriculumAgent.propose_next_ai_task () avant elle, CriticAgent.ai_check_task_success () s'appelle donc un certain nombre de fois en cas d'échec. Ici encore, il faut pointer qu'elle ne change strictement rien d'une itération à l'autre, ce qui paraît intriguant, pour la raison déjà évoquée.
Il est intéressant de relever que "reasoning" n'est pas mentionné : le LLM est invité à fournir une réponse en exposant le "raisonnement" qu'il a suivi pour la justifier, mais ce "raisonnement" n'est pas utilisé. Il peut s'agir d'une scorie, mais ce peut tout aussi bien être délibéré, car inviter le LLM à exposer son "raisonnement" est une technique de prompting, comme cela a été mentionné plus tôt, qui par définition doit permettre d'obtenir une réponse. Dans ces conditions, il faudrait s'interroger sur ce paradoxe : forcer la boîte noire à exposer comment elle procède au prétexte que cela la conduirait à produire une meilleure réponse, mais ne s'intéresser finalement qu'à la réponse et pas au "raisonnement" ? De quoi conduire à faire comme le devin qui multiplie les rites pour sonder les dieux, sachant qu'il n'a aucune garantie que les rites font qu'il est plus entendu, et quand bien même il l'est, que les dieux ne lui mentent pas...

Feedback sur le prompt

Voyager.step () récupère ainsi un booléen qui lui indique si le bot a réussi ou non à accomplir la tâche, et éventuellement un texte qui explique pourquoi.
Avant d'en tenir compte, la méthode teste un drapeau Voyager.reset_placed_if_failed, qu'il faut considérer à False, car il est initialisé ainsi par le constructeur de la classe et jamais modifié. En gros, s'il est à True et que la tâche a échoué, les objets déposés par le bot lui sont restitués.
Que le bot ait réussi ou non à accomplir la tâche, Voyager.step () appelle ensuite SkillManager.retrieve_skills () en lui passant la concaténation du contexte de la tâche et du résultat retourné par ActionAgent.summarize_chatlog (), séparés par deux retours à la ligne.
ActionAgent.summarize_chatlog () parcourt la liste des observations à la recherche d'entrées de type "onChat", qui ont été rajoutées à bot.cumulativeObs à la suite d'un événement qui survient chaque fois qu'une réécriture de bot.chat () est appelée (/env/mineflayer/lib/skillLoader.js) :
bot._chat = bot.chat;
bot.chat = (message) => {
	// action_count.chat++;
	bot.emit("chatEvent", "bot", message);
	bot._chat(message);
};
Pour rappel, bot.emit ("chatEvent", ...) entraîne l'appel au gestionnaire de "chatEvent" que le constructeur de onChat a rajouté au bot, lequel mémorise le message – si ce n'est pas une commande, mais un message de Minecraft – dans onChat.obs avant d'appeler bot.emit ("onChat"), laquelle rajoute une observation de type "onChat" à bot.cumulativeObs, laquelle observation contient notamment une entrée "onChat" dont la valeur retournée par onChat.observe () est onChat.obs, donc le message, laquelle purge onChat.obs dans la foulée – le gestionnaire est censé concaténer les messages dans onChat.obs, mais dans les faits, ce petit jeu se déroule à chaque message. Ouf !
Cela permet de tracer les messages que Minecraft affiche dans le chat en réponse aux commandes que Mineflayer lui donne, notamment pour faire accomplir au bot la tâche générée par le LLM, conformément au code JavaScript dans lequel le LLM l'a traduite.
ActionAgent.summarize_chatlog () récupère ces messages, et passe chacun à la moulinette :
def filter_item(message: str):
	craft_pattern = r"I cannot make \w+ because I need: (.*)"
	craft_pattern2 = (
		r"I cannot make \w+ because there is no crafting table nearby"
	)
	mine_pattern = r"I need at least a (.*) to mine \w+!"
	if re.match(craft_pattern, message):
		return re.match(craft_pattern, message).groups()[0]
	elif re.match(craft_pattern2, message):
		return "a nearby crafting table"
	elif re.match(mine_pattern, message):
		return re.match(mine_pattern, message).groups()[0]
	else:
		return ""
Comme il est possible de le constater, cette fonction recherche les messages par lesquels Minecraft signale certaines impossibilités d'accomplir une action : fabriquer un objet faute de matériau(x), fabriquer un objet faute d'établi, et miner un bloc faute d'outil adéquat. Elle extrait le motif évoqué par Minecraft, et le retourne.
C'est ainsi que ActionAgent.summarize_chatlog () retourne un texte qui exprime les besoins insatisfaits du bot, qui l'ont empêché d'accomplir la tâche :
chatlog = set()
for event_type, event in events:
	if event_type == "onChat":
		item = filter_item(event["onChat"])
		if item:
			chatlog.add(item)
return "I also need " + ", ".join(chatlog) + "." if chatlog else ""
Voyager.step () appelle donc SkillManager.retrieve_skills () en lui passant un texte qui peut ressembler à ça, la tâche étant "Craft 1 chest" :
Craft 1 chest with 8 planks of any kind of wood.

I also need 3 planks, a nearby crafting table.
Comme déjà vu, SkillManager.retrieve_skills () utilise ce texte pour effectuer une recherche pas similitude dans une base de données vectorielle et retourne un tableau de compétences sous forme d'entrées <programme >: { code: <code>, description: <description> }, où <programme> est le nom d'une fonction JavaScript.
Le lecteur attentif n'aura pas manqué de cerner ce qui change. SkillManager.retrieve_skills () a bien été appelée avant, mais c'était en lui passant le contexte de la tâche uniquement. Ici, c'est bien encore ce contexte, mais les besoins insatisfaits du bot pour accomplir la tâche y ont été rajoutés. Partant, il est possible que les compétences retournées ne soient pas identiques. C'est l'amorce d'une nouvelle itération.
De fait, avant de retourner un résultat, Voyager.step () met à jour les prompts qu'elle a utilisés pour demander à ActionAgent de générer le code JavaScript pour accomplir l'action :
system_message = self.action_agent.render_system_message(skills=new_skills)
human_message = self.action_agent.render_human_message(
	events=events,
	code=parsed_result["program_code"],
	task=self.task,
	context=self.context,
	critique=critique,
)
self.last_events = copy.deepcopy(events)
self.messages = [system_message, human_message]
Ainsi, le prompt système est mis à jour en reprenant la nouvelle liste de compétences dont il vient d'être question. Quant au prompt utilisateur, il est aussi mis à jour, pour sa part en tenant compte du code JavaScript qui a été généré et exécuté, et des produits de cette exécution, à savoir les observations – qui comprennent notamment les messages de Minecraft –, et la critique du résultat – une chaîne vide en cas de réussite –, toutes choses qui avaient été omises lors de la première constitution de ce prompt dans Voyager.reset (), appelée par Voyager.rollout () avant que cette dernière n'entreprenne d'appeler Voyager.step () en boucle.
Voyager.step () s'y reprend au plus Voyager.action_agent_task_max_retries fois – par défaut, 4 – pour faire accomplir la tâche par le bot, avant d'acter un échec. Dans tous les cas, elle retourne plusieurs choses :
  • messages, un tableau qui contient les prompts système et utilisateur mis à jour ;
  • reward, une constante à 0 ;
  • done, un drapeau à True si la tâche a pu être accomplie, ou si cela n'a pas été possible dans la limite du nombre de tentatives autorisées ;
  • info, un dictionnaire qui reprend :
    • l'énoncé de la tâche ;
    • le booléen retourné par CriticAgent.check_task_success () qui acte de sa réussite ou de son échec de son accomplissement par le bot :
    • Voyager.conversations – pour rappel, une liste de tuples formés du prompt système, du prompt utilisateur et de la réponse du LLM ;
    • le code JavaScript généré par le LLM ;
    • le nom de la dernière fonction asynchrone qui ne prend que bot en paramètre trouvée dans ce code.
Inutile de s'inquiéter de reward et de la reprise de Voyager.conversations dans info["conversations"] : ces informations ne sont jamais utilisées par la suite. Des scories d'une tentative d'apprentissage par renforcement, peut-être...

"Je l'aurai ! Un jour, je l'aurai !"

Le lecteur courageux apprendra avec joie que la ligne d'arrivée n'est plus qu'à quelques mètres. Mais avant de retourner dans Voyager.learn () à laquelle Voyager.rollout rend la main, il apparaît opportun de prendre du recul.
En effet, il faut relever que tout ce qui vient d'être vu après être rentré dans Voyager.rollout () se déroule dans le cadre d'un processus itératif qui vise à faire accomplir une tâche par le bot – parfois sans prise en compte apparente de l'erreur, ce qui est intriguant :
Fonction N Objet Feedback
CurriculumAgent
.propose_next_ai_task ()
5 Faire générer une tâche par le LLM Aucun
Voyager
.step ()
4
Consécutivement :
  • faire générer le code pour accomplir la tâche par le LLM ;
  • faire valider ce code par ActionAgent.process_ai_message () ;
  • faire exécuter ce code par VoyagerEnv.step () ;
  • faire évaluer si cela a permis au bot d'accomplir la tâche par CriticAgent.check_task_success ().
Dans le prompt utilisateur utilisé pour faire générer le code par le LLM, reprise :
  • du code généré par le LLM lors de l'itération précédente ;
  • des messages d'erreurs JavaScript fonctionnelles que son exécution a engendrées ;
  • des messages de Minecraft que son exécution a entraînés ;
  • des nouveaux états du bot et de son environnement au terme de cette exécution ;
  • de l'évaluation générée par le LLM de l'accomplissement de la tâche par le moyen de cette exécution – la critique.
ActionAgent
.process_ai_message ()
3 Faire valider ce code par Babel Aucun
CriticAgent
.ai_check_task_success ()
5 Faire évaluer l'accomplissement de la tâche par le LLM Aucun
Comme il est possible de le constater, Voyager tente souvent de parvenir à un résultat, tout particulièrement quand il s'agit de l'obtenir du LLM.
C'est tout à fait caractéristique de l'exploitation d'un système qui produit une réponse qui dépend non seulement du fond, mais aussi de la forme d'une demande, si bien que si la "créativité" interne du système a été neutralisée – l'on rappelle que la température du LLM est toujours à 0 –, il ne faut pas exclure que ce système produise la bonne réponse si l'on se contente de faire preuve de créativité externe en jouant simplement sur la forme de la demande.
C'est aussi problématique, car dès lors qu'il s'agit de s'adresser au LLM un nombre de fois qui certes peut être limité, mais pas prévu d'avance, cela rend tant l'efficacité que l'efficience imprévisibles – s'adresser au LLM consomme des ressources, qui ne sont pas gratuites, sans garantie de résultat. Comme évoqué dans un article consacré à la génération de code avec ChatGPT en avril 2023, tel le client de Palace, l'on peut s'acharner longtemps : "Je l'aurai ! Un jour, je l'aurai !"... mais quand ?

Les leçons de la vie

Voyager.step () retourne ses beaux résultats à Voyager.rollout (), et si le bot a réussi à accomplir la tâche ou s'il n'est plus possible de lui faire tenter, cette méthode les retourne à Voyager.learn ().
Désormais, avant la nouvelle itération de la boucle principale – que l'on rappelle être par défaut limitée à 160 itérations –, Voyager.learn () peut tirer les leçons de tout ce qui vient de se passer, de la génération d'une tâche à l'évaluation de son accomplissement par le bot.
Sans surprise, la méthode commence par appeler SkillManager.add_new_skill () en lui passant le dictionnaire info que Voyager.step () a initialement retourné, dont l'on rappelle ici ce qu'il contient :
info = {
	"task": <énoncé de la tâche>,
	"success": <booléen à True si le bot a réussi à accomplir la tâche>,
	"conversations": [
		( <prompt système>, <prompt utilisateur>, <réponse du LLM> ),
		<...>
	],
	"program_code": <code JavaScript généré par le LLM>,
	"program_name": <nom de la dernière fonction asychrone qui ne prend que bot en paramètre trouvée dans ce code>
}
Pour rappel encore, il a déjà été question de SkillManager.add_new_skill (), pour déterminer ce qu'est une compétence au sens de Voyager exactement :
  • dans la base de données vectorielle des compétences, c'est une entrée qui comprend les champs suivants :
    texts=<description de la fonction en six phrases au plus sur une ligne>
    ids=<nom d'une fonction asynchrone qui ne prend que bot en paramètre>
    metadatas=[{"name": <nom de la focntion asynchrone>}]
    
  • dans le fichier /ckpt/skill/skills.json, c'est une entrée au format suivant dans un dictionnaire :
    <nom de la fonction asynchrone qui ne prend que bot en paramètre>: {
    	"code": <code JavaScript de la fonction>,
    	"description": <description de la fonction en six phrases au plus sur une ligne>
    }
    
Comme il est possible de le constater, SkillManager.add_new_skill () trouve dans info tout ce qu'il lui faut, à l'exception de la description. Qu'à cela ne tienne, elle se contente d'appeler SkillManager.generate_skill_description () pour demander au LLM de la générer. Inévitablement, cela passe par des prompts système et utilisateur.
Pour ce qui concerne le prompt système, ce n'est que la reprise du contenu d'un fichier texte, en l'espèce /prompts/skill.txt. Le prompt est assez long, et l'on en détaillera la structure en s'appuyant sur de larges extraits – le lecteur est renvoyé au fichier pour consulter le prompt dans son intégralité :
  • il donne un rôle au LLM :
    You are a helpful assistant that writes a description of the given function written in Mineflayer javascript code.
    
  • il lui donne des règles à respecter :
    1) Do not mention the function name.
    2) Do not mention anything about `bot.chat` or helper functions.
    3) There might be some helper functions before the main function, but you only need to describe the main function.
    4) Try to summarize the function in no more than 6 sentences.
    5) Your response should be a single line of text.
    
  • il lui donne un exemple (extrait) :
    For example, if the function is:
    
    async function mineCobblestone(bot) {
    	<...>
    }
    
    The main function is `mineCobblestone`.
    
    Then you would write:
    
    The function is about mining 8 cobblestones using a wooden pickaxe. First check if a wooden pickaxe is in the inventory. If not, craft one. If the wooden pickaxe is available, equip the wooden pickaxe in the hand. Next, explore the environment until finding a stone block. Once a stone block is found, mine a total of 8 cobblestone blocks using the wooden pickaxe.
    
Pour ce qui concerne le prompt utilisateur, c'est donc simplement la concaténation de info["program_code"] et d'une chaîne qui désigne par info["program_name"] la dernière fonction asynchrone qui ne prend que bot en paramètre qu'il contient.
Désormais, SkillManager.add_new_skill () peut ajouter la compétence à la base de données vectorielle et au fichier /ckpt/skill/skills.jon, et rendre la main à Voyager.step ().
Pour finir Voyager.step () appelle CurriculumAgent.update_exploration_progress () en lui passant info. Cette petite méthode se contente de rajouter l'énoncé de la tâche au tableau CurriculumAgent.completed_tasks ou au tableau CurriculumAgent.failed_tasks, selon que le bot a réussi à accomplir la tâche ou non. Noter que la tâche n'est pas enregistrée si jamais son énoncé commence par "Deposit useless items into the chest at", sans doute car c'est une tâche trop basique.
Pourquoi inventorier ainsi les tâches réussies et ratées par le bot ? Car pour rappel, le prompt utilisateur utilisé par l'agent Curriculum pour demander au LLM de générer une nouvelle tâche reprend ces inventaires :
Completed tasks so far: <concaténation des entrées de CurriculumAgent.competed_tasks séparées par une virgule>
Failed tasks that are too hard: <concaténation des entrées de CurriculumAgent.failed_tasks séparées par une virgule>
Et là, courageux lecteur, c'est vraiment la FIN !

Il reste des bouts de code, ici et là

La lecture attentive du code de Voyager permet de constater la présence de bouts de code qui ne sont pas utilisés, tout particulièrement :
  • la méthode Voyager.decompose_task () ;
  • la méthode Voyager.inference () ;
  • la méthode CurriculumAgent.decompose_task () ;
  • le prompt /prompts/curriculum_task_decomposition.txt ;
  • Voyager.conversations, l'historique des échanges avec le LLM lors de la génération du code ;
  • reward, retourné par Voyager.step (), toujours à 0 ;
  • le raisonnement (texte qui suit "Reasoning:") dans le curriculum généré par le LLM ;
  • le raisonnement (champ "reasoning") dans la critique au format JSON générée par le LLM.
Aussi, VoyagerEnv hérite de la classe Env de Gymnasium, un package Python d'OpenAI présenté comme "an API standard for reinforcement learning with a diverse collection of reference environments", mais sans qu'aucune fonctionnalité de Gymnasium n'apparaisse utilisée.
Au-delà, comme expliqué au début de cet article, le parti pris a été d'ignorer la gestion des checkpoints, pour ne pas encombrer le propos – de toute manière, ils ne servent qu'à arrêter et reprendre Voyager. Voyager en génère régulièrement, sous forme de différents fichiers. Le répertoire /skill_library/, qui contient les résultats de plusieurs essais, contient un README qui recense ces points, assez nombreux, que l'on retrouve dans /ckpt/ au terme d'une exécution :
├── action
│   └── chest_memory.json
├── curriculum
│   ├── completed_tasks.json
│   ├── failed_tasks.json
│   ├── qa_cache.json
│   └── vectordb
├── events
└── skill
    ├── code
    │   ├── catchThreeFishWithCheck.js
    │   ├── collectBamboo.js
    │   ├── ...
    ├── description
    │   ├── catchThreeFishWithCheck.txt
    │   ├── collectBamboo.txt
    │   └── ...
    ├── skills.json
    └── vectordb
Pour finir, il reste donc quelques points obscurs dans le code de Voyager qui a été passé en revue :
  • Pourquoi l'inventaire du bot repris dans le prompt utilisateur que l'agent Curriculum utilise pour faire générer par le LLM une tâche est-il purgé de tous les objets dont les noms correspondent à l'expression régulière r".*_log|.*_planks|stick|crafting_table|furnace|cobblestone|dirt|coal|.*_pickaxe|.*_sword|.*_axe" tant que le bot n'a pas réussi au moins 7 tâches ?
  • Pourquoi le code repris dans le prompt système que l'agent Action utilise pour faire générer par le LLM le code qui doit permettre d'accomplir la tâche est-il tiré des fichiers du répertoire /control_primitives_context/, tandis que le code des compétences transmis à Voyager.step () est notamment tiré des fichiers du répertoire /control_primitives/ ?
Par ici pour la fin...
Voyager, l’agent IA qui joue à Minecraft (3/4)