Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions

« Tests passed », aucun fichier sur le disque : déboguer un agent local depuis la plomberie

Mon agent local m'annonçait « Build succeeded » et « Tests passed », et je ne trouvais aucun fichier dans le projet. J'ai accusé le modèle un peu vite : le premier coupable, c'était mon montage Docker. Cet article ouvre la partie de la série consacrée au piège du sandbox Hermes.

Par Nicolas Cousin — Publié le 23 octobre 2026

« Tests passed », aucun fichier sur le disque : déboguer un agent local depuis la plomberie

TL;DR

Mon agent local m'annonçait un build réussi et des tests au vert, et le projet restait vide. J'ai d'abord accusé le modèle. En réalité, le projet n'était même pas monté dans le conteneur de l'agent, et tant que ce n'était pas réglé, je ne pouvais rien dire des modèles. Une fois le montage corrigé, Devstral écrit bel et bien ses fichiers. Qwen2.5-Coder 7B, lui, testé plus tard dans OpenCode, continue d'annoncer un appel d'outil sans l'exécuter, du moins avec mon installation Ollama. Ce que j'en retiens : on vérifie sur le disque, pas dans le compte rendu de l'agent.


Sommaire


Le décor

Un VPS sans GPU, avec Ollama et Hermes (l'agent de Nous Research) qui tournent dans deux conteneurs Docker sur un réseau privé agentic. J'ai détaillé l'installation dans un article précédent. Je ne le savais pas encore en l'écrivant, mais le problème de cet article-ci était déjà dedans.

Le symptôme

J'ai commencé avec qwen2.5-coder:7b : environ 7,6 milliards de paramètres, quantifié en Q4_K_M, et marqué « tools » par Ollama, donc censé savoir appeler des outils. Dans Hermes, tout avait l'air de bien se passer. Des write_file … défilaient, puis un terminal("dotnet build"), puis « Build succeeded » et « Tests passed ». Dans le projet, pourtant, pas un fichier.

Devstral 24B, de son côté, n'avait pas fait beaucoup mieux sur un benchmark plus ambitieux : une petite API ASP.NET Core avec EF Core, PostgreSQL, un service, des tests xUnit, des scénarios Gherkin et une pipeline GitHub Actions. Environ seize minutes et demie de travail, pour une réponse textuelle au lieu d'une vraie exécution, une route incorrecte, un service oublié dans l'injection de dépendances, une structure de projet incohérente, des GitHub Actions qui ciblaient .NET 6, du Gherkin non exécutable, et aucun build réel démontré. J'en avais tiré une conclusion provisoire : être bon pour générer du code ne fait pas un bon agent.

Je ne vais pas faire semblant : à ce stade, j'étais surtout agacé. Ces benchmarks tournent sur CPU, chaque essai se compte en minutes, et je voyais l'aiguille de ma montre tourner pour rien. Heureusement, pendant ces attentes, j'aidais ma fille à faire ses devoirs. Elles n'ont pas été complètement perdues pour tout le monde.

J'avais donc deux modèles qui semblaient inventer leurs appels d'outils. En fait, je ne me posais pas la bonne question.

Le projet n'était pas dans le conteneur

Le seul montage d'Hermes, c'était ~/.hermes vers /opt/data, son répertoire d'état. Le projet sur lequel je lui demandais de travailler n'existait tout simplement pas dans le conteneur. C'est exactement la commande de l'article d'installation, et c'est aussi la seule que montrent les exemples de la documentation Docker d'Hermes (consultée en octobre 2026). Dans cette doc, /opt/data sert à l'état de l'agent, pas à son espace de travail. Elle n'a rien de faux, elle ne couvre simplement que la moitié du besoin.

Quoi que l'agent ait écrit, ça ne pouvait donc pas atterrir dans le projet. Peut-être que les écritures sont parties ailleurs, dans la couche inscriptible du conteneur ou dans le seul répertoire monté, celui de l'état. Peut-être qu'elles n'ont jamais eu lieu. Sans transcription, je ne peux pas trancher, et c'est bien là le problème : tant que l'environnement cache le résultat, on ne peut rien conclure sur le modèle. Autrement dit, ces minutes de benchmark n'avaient pas mesuré grand-chose, à part le temps perdu, ce qui n'a pas arrangé ma frustration.

Le correctif, et trois détails à ne pas rater

Tout tient dans -v "$PWD:/workspace", qui fait du répertoire courant l'espace de travail de l'agent. Je l'ai rangé dans une fonction zsh :

unalias hermes 2>/dev/null
hermes() {
  sudo docker run -it --rm \
    --network agentic \
    -v "$HOME/.hermes:/opt/data" \
    -v "$PWD:/workspace" \
    -w /workspace \
    -e HERMES_WRITE_SAFE_ROOT=/workspace \
    hermes-local "$@"
}

D'abord zsh. J'avais déjà un alias hermes, et au moment de définir une fonction du même nom, j'ai eu droit à :

defining function based on alias `hermes'
parse error near `()'

Avec la syntaxe nom (), zsh refuse de définir une fonction dont le nom vient d'un alias, sauf si l'option ALIAS_FUNC_DEF est activée. D'où le unalias en première ligne.

Ensuite HERMES_WRITE_SAFE_ROOT, que j'ai réglé sur /workspace au moment de revalider. La documentation actuelle la présente comme une restriction : elle bloque write_file et patch en dehors des racines listées. Je n'ai pas noté si ma version l'exigeait, ni pourquoi. Je la garde comme garde-fou explicite pour ces deux outils, en sachant qu'elle ne couvre pas les commandes shell : là, seules les permissions font barrière.

Enfin les permissions, justement. Hermes tourne sous l'UID/GID 10000, alors que le projet appartient à mon utilisateur ubuntu (1000). J'ai réglé ça avec des ACL :

sudo apt install -y acl
sudo setfacl -R -m u:1000:rwX,u:10000:rwX <projet>
sudo setfacl -R -d -m u:1000:rwx,u:10000:rwx <projet>

Le premier setfacl s'occupe de l'existant (le X majuscule n'ajoute le droit d'exécution qu'aux répertoires et aux fichiers déjà exécutables). Le second pose une ACL par défaut, dont hériteront les fichiers créés ensuite, que ce soit par l'agent ou par moi.

Devstral écrit vraiment

Pour revalider, j'ai demandé un vrai write_file à un modèle cloud (gpt-5.6-sol), puis à Devstral. Cette fois, deux fichiers sont apparus sur le VPS : sol-tool-test.txt et devstral-tool-test.txt.

Devstral, que Mistral présente comme pensé pour tourner dans des agents de code, n'était donc pas « incapable d'agir ». Le « aucun build démontré » de mon premier benchmark mesurait surtout mon environnement. Les défauts du code lui-même, en revanche, restent à réévaluer.

Qwen, lui, annonce sans exécuter

Conclure que tout venait du montage serait pourtant faux.

Plus tard, dans OpenCode (un autre harness, lancé dans un Dev Container rootless), j'ai demandé aux deux modèles de créer un fichier avec un contenu précis. Devstral 24B a fait un vrai appel write : fichier créé, contenu correct, en un peu moins de 14 minutes, à environ 0,4 token/s, avec une erreur de transport et un retry au passage. Qwen2.5-Coder 7B, dans sa variante 64K, a bien compris la consigne et présenté un write(…) qui tenait la route, en 8 minutes 35. Aucun fichier créé.

Je précise que je n'ai pas retesté Qwen dans Hermes après le correctif.

Pour autant, je ne vais pas en conclure que Qwen2.5-Coder ne sait pas appeler d'outils. Une issue chez Hermes (#5867), encore ouverte en octobre 2026, décrit le même symptôme avec qwen2.5-coder:14b via Ollama : les appels reviennent sous forme de JSON dans le texte de la réponse, au lieu du champ structuré tool_calls, et Hermes les traite comme une réponse ordinaire, sans jamais les exécuter. Le contournement signalé consiste à changer de provider côté Hermes. La documentation function calling de Qwen2.5, qui ne parle pas de la variante Coder, prévient d'ailleurs que les modèles ne respectent pas toujours le protocole, même avec le bon prompt et le bon template.

Ce que je ne referais pas

Juger un modèle sur ce qu'il raconte. « Build succeeded », c'est du texte généré. Ça n'a rien d'un code de sortie.

Compter sur -v pour me prévenir d'une erreur. Quand le chemin source n'existe pas, -v le crée sans rien dire, sous forme de répertoire vide, alors que --mount type=bind,… échoue franchement. Une simple faute de frappe dans le chemin suffit à reproduire exactement ce symptôme, sans le moindre message.

Oublier le revers de $PWD. Si on lance la fonction depuis $HOME, l'agent reçoit tout ce que ses permissions lui laissent atteindre, y compris ~/.hermes (configuration, jetons OAuth, clés d'API), monté une deuxième fois sous /workspace, et le safe root couvre alors tout $HOME. Il faut donc la lancer depuis la racine du projet, et n'appliquer les ACL qu'au projet, jamais à $HOME ni à une arborescence qui contient des .env ou d'autres secrets.

Si c'était à refaire, je regarderais d'abord du côté de HERMES_UID et HERMES_GID, que la documentation et le docker-compose.yml actuels d'Hermes proposent pour aligner l'utilisateur du conteneur sur le propriétaire du montage. Je ne sais pas si c'était déjà disponible dans ma version, ni avec mon image hermes-local construite en local, donc à vérifier chez vous.

Dans quel ordre chercher

Avec le recul, quand le disque ne confirme pas le succès annoncé, voici l'ordre dans lequel je chercherais :

  1. L'environnement. Le projet est-il visible dans le conteneur, au bon chemin, et l'utilisateur de l'agent peut-il y écrire ? Un test bête (« crée ce fichier avec ce contenu ») et un ls côté hôte suffisent à le savoir.
  2. Le harness et son parseur. L'appel d'outil arrive-t-il sous forme structurée, ou comme du texte qui y ressemble ? Si un modèle cloud réussit le même test, l'environnement et le harness sont hors de cause. Côté Ollama, il reste à regarder si l'appel revient dans tool_calls ou dans le texte.
  3. Le modèle. Seulement à ce moment-là, et en jugeant sur le résultat concret : le fichier est là, le build relancé à la main passe, le code de sortie est lu.

Et ce n'est pas un travers réservé aux petits modèles locaux. Des travaux récents (Guo et al., ICML 2026, Advani, 2026), menés surtout sur des modèles de pointe, décrivent des agents qui affirment avoir terminé alors que l'environnement dit le contraire.


Le sandbox d'Hermes voit enfin le projet, et un modèle local y écrit pour de vrai. Il me reste à rejouer les tests que l'environnement avait faussés. En attendant, j'ai au moins appris à ne plus croire l'agent sur parole et à aller regarder le disque.