llamagent — un agent de code minimal autour d'Ollama
Objectif
Ce projet a un but simple : comprendre comment fonctionne un agent de code en le construisant de A à Z en Python. Un agent de code est un programme qui reçoit une consigne (« trouve le bug dans ce script et corrige-le »), lit des fichiers, lance des commandes et modifie du code en s'appuyant sur un LLM.
Le but n'est pas d'obtenir le meilleur agent possible mais de comprendre chaque brique : la boucle agentique, le harness, les outils, le prompt système et les skills. Aucun framework (pas de LangChain) : une seule dépendance, requests, et tout le reste écrit à la main. Le projet s'est appelé mini-agent pendant son développement, puis llamagent pour sa publication sur PyPI (étape 18). Le code est sur GitHub et le paquet sur PyPI. Pour l'essayer sans suivre le tutoriel (il faut un serveur Ollama avec qwen3.5:4b) :
uv tool install llamagent # ou : pipx install llamagent llamagent
Le LLM tourne en local sur mon NAS (voir le projet NAS Lab) : qwen3.5:4b servi par Ollama sur une RTX 3050 de 6 Go. Un modèle aussi petit impose des choix : peu d'outils (5 au maximum), des descriptions très claires et des résultats d'outils tronqués.
Le tutoriel a deux parties. Les étapes 0 à 8 construisent l'agent pas à pas. Les étapes 9 à 18 en font un outil du quotidien, sur le modèle d'opencode : lancé dans n'importe quel projet, réglable en cours de route, capable de recevoir des fichiers tagués, de reprendre une conversation et de compacter son contexte, avec une interface soignée, jusqu'à sa publication sur PyPI.

Architecture
Le modèle ne sait que produire du texte. Tout le reste — lire un fichier, exécuter une commande, demander une confirmation, s'arrêter — est fait par le code qui l'entoure : le harness. Au cœur du harness se trouve la boucle agentique, la seule partie qui parle au LLM.
Le code est organisé en deux parties : steps/ contient un script autonome par étape pour les étapes 1 à 4 (tout dans un fichier, pour apprendre), puis à partir de l'étape 5 le code passe dans src/llamagent/, découpé en modules. Les scripts de steps/ deviendraient sinon aussi longs que la version finale.
llamagent/ ├── pyproject.toml # uv + hatchling, une seule dépendance : requests ├── .python-version # 3.11 ├── README.md # aussi la page du projet sur PyPI ├── LICENSE ├── .github/workflows/ # CI et publication sur PyPI (étape 18) │ ├── ci.yml │ └── release.yml ├── scripts/check_package.py ├── steps/ # étapes 1 à 4 : un script autonome par étape │ ├── step1_hello.py │ ├── step2_chat.py │ ├── step3_tool.py │ └── step4_loop.py ├── src/llamagent/ # à partir de l'étape 5 : la version modulaire │ ├── __init__.py │ ├── config.py # configuration et limites │ ├── llm.py # appel à Ollama │ ├── tools.py # outils + registre │ ├── guards.py # garde-fous │ ├── agent.py # boucle agentique │ ├── session.py # état de la conversation (partie 2) │ ├── history.py # sauvegarde des conversations (partie 2) │ ├── context.py # mesure et compactage du contexte (partie 2) │ ├── commands.py # commandes /... (partie 2) │ ├── ui.py # interface : mascotte, boucle visible (partie 2) │ ├── cli.py # point d'entrée `llamagent` │ ├── prompts/ # dans le paquet depuis l'étape 18 │ │ ├── AGENT.md # prompt système │ │ └── COMPACT.md # consignes de résumé │ └── skills/ # skills globales, en Markdown │ ├── corriger-un-bug.md │ └── ecrire-tests-pytest.md ├── demo/stats.py # script buggé pour la démo └── tests/ # pytest, Ollama remplacé par un faux modèle
Configuration
Tout ce qui dépend de la machine se règle par variables d'environnement :
OLLAMA_URL: adresse du serveur Ollama (défauthttp://localhost:11434, l'adresse standard d'Ollama sur la machine ; jusqu'à l'étape 17, c'était celle de mon NAS).OLLAMA_MODEL: modèle utilisé (défautqwen3.5:4b).LLAMAGENT_WORKDIR(facultatif) : le seul dossier où l'agent peut lire, écrire et lancer des commandes. Par défaut, c'est le dossier depuis lequel on lance l'agent. S'il est défini, il doit exister : sinon l'agent s'arrête au démarrage.NO_COLOR: si elle existe, les logs ne sont pas colorés.
export OLLAMA_URL="http://192.168.1.76:11434" export OLLAMA_MODEL="qwen3.5:4b"
$env:OLLAMA_URL = "http://192.168.1.76:11434" $env:OLLAMA_MODEL = "qwen3.5:4b"
Une variable définie dans PowerShell reste active jusqu'à la fermeture du terminal. Pour l'effacer : Remove-Item Env:LLAMAGENT_WORKDIR (macOS : unset LLAMAGENT_WORKDIR).
Ces réglages, la température, la taille du contexte et les commandes autorisées peuvent aussi être écrits dans un fichier config.toml (global ou propre au projet) ou passés en options : llamagent --model qwen3.5:9b --temperature 0.5. Voir l'étape 12.
Les valeurs par défaut du modèle et les limites des garde-fous sont des constantes de config.py : TEMPERATURE = 0.2, NUM_CTX = 8192, REQUEST_TIMEOUT = 120 s, MAX_TOURS = 15, COMMAND_TIMEOUT = 30 s, MAX_OUTPUT_CHARS = 4000.
Chaque appel au modèle envoie le même JSON à POST /api/chat :
{
"model": "qwen3.5:4b",
"messages": [ ... toute la conversation ... ],
"tools": [ ... schémas des outils (à partir de l'étape 3) ... ],
"stream": false,
"think": false,
"options": {"temperature": 0.2, "num_ctx": 8192}
}📋 Étapes du tutoriel
Partie 1 — construire l'agent
- Créer le projet (uv, hatchling, configuration)
- Premier appel à /api/chat
- Chat avec mémoire
- Premier outil (sans l'exécuter)
- La boucle agentique
- Registre d'outils et passage dans src/
- Garde-fous
- Prompt système et skills
- Robustesse et démo
Partie 2 — vers un agent comme opencode
- Lancer llamagent dans n'importe quel projet
- Session et commandes /…
- Taguer des fichiers avec @
- Options et fichiers config.toml
- Compteur de contexte et edit_file
- Commandes autorisées sans confirmation
- Sauvegarder et reprendre les conversations
- Mesurer et compacter le contexte
- Une interface plus jolie
- Publier sur PyPI : CI/CD
Tutoriel — construire l'agent étape par étape
Chaque étape ajoute une seule brique : objectif, concept, code complet, commande à lancer, ce qu'on doit observer et pièges à surveiller. Le but est de pouvoir tout réécrire soi-même à partir de cette page.
Objectif
Poser un squelette de projet Python installable, avec une seule dépendance (requests) et une configuration lue dans les variables d'environnement. Aucun appel au LLM à cette étape.
Concept
uv gère à la fois la version de Python, l'environnement virtuel et les dépendances : il remplace pip + venv + pyenv, et se comporte de la même façon sous Windows et macOS. hatchling est le build backend : l'outil qui transforme le dossier src/ en paquet installable.
Avec le layout src/, on ne peut pas importer le paquet sans l'installer : on teste donc toujours ce qui sera réellement distribué. Et comme l'URL d'Ollama et le modèle viennent de l'environnement, le même code tourne contre le NAS, contre un Ollama local ou avec un autre modèle, sans modification.
uv init --package --build-backend hatch --name llamagent llamagent cd llamagent uv add requests uv run llamagent
[project]
name = "llamagent"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"requests>=2.34.2",
]
[project.scripts]
llamagent = "llamagent.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build""""Configuration de llamagent.
Tout ce qui peut changer d'une machine à l'autre (adresse d'Ollama, modèle)
est lu dans les variables d'environnement, avec des valeurs par défaut
adaptées à mon NAS. Le reste (température, taille du contexte) est fixé ici
pour que tout le projet utilise les mêmes réglages.
"""
import os
from dataclasses import dataclass
# Valeurs par défaut : Ollama sur le NAS, modèle 4B qui tient dans 6 Go de VRAM.
DEFAULT_OLLAMA_URL = "http://192.168.1.76:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
# Température basse : on veut un agent prévisible, pas créatif.
TEMPERATURE = 0.2
# Taille de la fenêtre de contexte (en tokens) demandée à Ollama.
# 8192 est un bon compromis pour un modèle 4B sur une carte de 6 Go.
NUM_CTX = 8192
@dataclass(frozen=True)
class Config:
"""Réglages de connexion à Ollama (immuables une fois chargés)."""
ollama_url: str
model: str
temperature: float = TEMPERATURE
num_ctx: int = NUM_CTX
@property
def chat_url(self) -> str:
"""URL de l'API native d'Ollama (et non la couche /v1 compatible OpenAI)."""
return f"{self.ollama_url}/api/chat"
def options(self) -> dict:
"""Bloc `options` envoyé à Ollama à chaque appel."""
return {"temperature": self.temperature, "num_ctx": self.num_ctx}
def load_config() -> Config:
"""Construit la configuration à partir des variables d'environnement."""
url = os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL)
model = os.environ.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
# On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
return Config(ollama_url=url.rstrip("/"), model=model)"""Point d'entrée de la ligne de commande : `uv run llamagent`.
À l'étape 0, il se contente d'afficher la configuration chargée.
La boucle agentique viendra se brancher ici dans les étapes suivantes.
"""
from llamagent.config import load_config
def main() -> None:
config = load_config()
print("llamagent — configuration chargée :")
print(f" OLLAMA_URL = {config.ollama_url}")
print(f" OLLAMA_MODEL = {config.model}")
print(f" options = {config.options()}")
print(f" endpoint = {config.chat_url}")
if __name__ == "__main__":
main()Cette version de config.py est celle de l'étape 0 : elle sera complétée à l'étape 6 (dossier de travail, limites des garde-fous) et à l'étape 7 (dossiers prompts/ et skills/). La version finale est dans le code complet.
Ce qu'on observe
Avec les valeurs par défaut :
$ uv run llamagent
llamagent — configuration chargée :
OLLAMA_URL = http://192.168.1.76:11434
OLLAMA_MODEL = qwen3.5:4b
options = {'temperature': 0.2, 'num_ctx': 8192}
endpoint = http://192.168.1.76:11434/api/chatEn surchargeant l'environnement (le « / » final de l'URL est bien retiré) :
$ OLLAMA_URL=http://localhost:11434/ OLLAMA_MODEL=qwen3.5:0.8b uv run llamagent
llamagent — configuration chargée :
OLLAMA_URL = http://localhost:11434
OLLAMA_MODEL = qwen3.5:0.8b
options = {'temperature': 0.2, 'num_ctx': 8192}
endpoint = http://localhost:11434/api/chatSous Windows, la même surcharge s'écrit en PowerShell :
$env:OLLAMA_URL = "http://localhost:11434" $env:OLLAMA_MODEL = "qwen3.5:0.8b" uv run llamagent
Pièges rencontrés
uv initfixerequires-pythonsur la version de Python présente sur la machine (ici>=3.13). Je l'ai ramené à>=3.11et j'ai épinglé.python-versionsur 3.11 : on développe sur la version minimale supportée, et uv la télécharge tout seul si besoin.- Déclarer
readme = "README.md"dans le pyproject sans créer le fichier fait échoueruv add requests: uv construit le paquet à chaque synchronisation, et hatchling refuse de le construire s'il manque un fichier déclaré. - Une URL saisie avec un « / » final donnerait
http://…:11434//api/chat: d'où lerstrip("/").
Objectif
Envoyer une question au modèle par HTTP et afficher sa réponse. Rien d'autre.
Concept
Un LLM servi par Ollama n'est qu'un serveur HTTP. On envoie en POST sur /api/chat un JSON qui contient le modèle, la liste des messages (chacun a un role : system, user, assistant ou tool, et un content) et des réglages.
"stream": false: une seule réponse complète, plus simple à lire qu'un flux de morceaux."think": false: désactive le mode « réflexion » de Qwen 3.5. Sur un 4B, ce raisonnement préalable coûte surtout du temps et du contexte.- On utilise l'API native plutôt que la couche
/v1compatible OpenAI pour voir le vrai format d'Ollama : champthink, blocoptions, arguments d'outils en objet JSON.
"""Étape 1 — Premier appel à Ollama.
Objectif : envoyer UNE question au modèle via l'API native /api/chat et
afficher sa réponse. Pas d'historique, pas d'outil : juste une requête HTTP.
Lancer :
uv run python steps/step1_hello.py
uv run python steps/step1_hello.py "Ta question ici"
"""
import json
import os
import sys
import requests
# Même configuration que src/llamagent/config.py, recopiée ici pour que
# le script reste autonome (un seul fichier à lire pour comprendre l'étape).
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://192.168.1.76:11434").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:4b")
def chat(messages: list[dict]) -> dict:
"""Envoie la conversation à Ollama et renvoie la réponse JSON complète."""
payload = {
"model": OLLAMA_MODEL,
"messages": messages, # toute la conversation, à chaque appel
"stream": False, # une seule réponse complète, pas de flux token par token
"think": False, # pas de « réflexion » cachée avant la réponse
"options": {"temperature": 0.2, "num_ctx": 8192},
}
# Le premier appel peut être lent : Ollama charge le modèle en VRAM.
response = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)
response.raise_for_status() # lève une exception si HTTP 4xx / 5xx
return response.json()
def main() -> None:
question = " ".join(sys.argv[1:]) or "Explique en une phrase ce qu'est un agent de code."
messages = [{"role": "user", "content": question}]
print(f"POST {OLLAMA_URL}/api/chat (modèle : {OLLAMA_MODEL})")
print(f"Question : {question}\n")
try:
data = chat(messages)
except requests.ConnectionError:
print(f"Impossible de joindre Ollama sur {OLLAMA_URL}. Vérifie OLLAMA_URL.")
sys.exit(1)
# On affiche la réponse brute une fois : c'est utile de voir ce que
# renvoie vraiment l'API avant de n'en garder qu'un champ.
print("Réponse brute d'Ollama :")
print(json.dumps(data, indent=2, ensure_ascii=False))
# Ce qui nous intéresse : le message de l'assistant.
print("\nRéponse du modèle :")
print(data["message"]["content"])
# Les durées sont en nanosecondes.
tokens = data.get("eval_count", 0)
seconds = data.get("eval_duration", 0) / 1e9
if seconds:
print(f"\n{tokens} tokens générés en {seconds:.1f} s ({tokens / seconds:.1f} tokens/s)")
if __name__ == "__main__":
main()Lancer
uv run python steps/step1_hello.py uv run python steps/step1_hello.py uv run python steps/step1_hello.py "Qu'est-ce qu'une boucle agentique ?"
Ce qu'on doit observer
Une réponse JSON avec model, message ({"role": "assistant", "content": ...}), "done": true, done_reason et des compteurs : prompt_eval_count (tokens lus), eval_count (tokens générés), et des durées en nanosecondes (total_duration, load_duration, eval_duration).
Au premier lancement, load_duration est grand : Ollama charge le modèle en VRAM. Au second, il est presque nul. La dernière ligne donne la vitesse en tokens/s : un bon repère pour la suite.
Pièges à surveiller
- Premier appel lent : d'où le
timeout=120. Avec un timeout trop court, la requête échoue pendant le chargement du modèle. - Un nom de modèle mal écrit donne une erreur HTTP 404 :
raise_for_status()la transforme en exception lisible. - Sans
"think": false, le modèle peut « réfléchir » longuement avant de répondre.
Objectif
Discuter sur plusieurs tours, et comprendre d'où vient la « mémoire ».
Concept
L'API est sans état : Ollama ne garde aucune conversation entre deux requêtes. La mémoire, c'est la liste messages que notre programme renvoie en entier à chaque appel : la question, puis la réponse, puis la question suivante…
Conséquences : chaque tour coûte plus cher que le précédent (le modèle relit tout), et la mémoire est bornée par num_ctx. Au-delà de 8192 tokens, Ollama tronque le début de la conversation sans prévenir.
"""Étape 2 — Chat avec mémoire.
La « mémoire » du modèle n'est qu'une liste Python : `messages`.
À chaque tour, on y ajoute la question, on envoie TOUTE la liste à Ollama,
puis on y ajoute la réponse. Le modèle lui-même ne retient rien entre
deux appels : si on vide la liste, il a tout « oublié ».
Commandes : /messages (voir la liste envoyée), /oublie (la vider), /quit
Lancer :
uv run python steps/step2_chat.py
"""
import json
import os
import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://192.168.1.76:11434").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:4b")
def chat(messages: list[dict]) -> dict:
"""Envoie toute la conversation et renvoie le message de l'assistant."""
payload = {
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"think": False,
"options": {"temperature": 0.2, "num_ctx": 8192},
}
response = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)
response.raise_for_status()
return response.json()["message"]
def main() -> None:
messages: list[dict] = [] # toute la mémoire de la conversation est ici
print(f"Chat avec {OLLAMA_MODEL}. Commandes : /messages, /oublie, /quit")
while True:
try:
text = input("\nvous > ").strip()
except (EOFError, KeyboardInterrupt):
break
if not text:
continue
if text == "/quit":
break
if text == "/messages":
print(json.dumps(messages, indent=2, ensure_ascii=False))
continue
if text == "/oublie":
messages.clear()
print("(liste vidée : le modèle a tout « oublié »)")
continue
messages.append({"role": "user", "content": text})
reply = chat(messages) # on envoie TOUT l'historique
messages.append(reply) # et on garde la réponse pour le prochain tour
print(f"\nmodèle > {reply['content']}")
print(f"(historique : {len(messages)} messages)")
if __name__ == "__main__":
main()Lancer
uv run python steps/step2_chat.py
Ce qu'on doit observer
- « Je m'appelle Nils. » puis « Comment je m'appelle ? » : le modèle répond Nils, parce que la première question est dans la liste.
/messagesaffiche exactement ce qui est envoyé au modèle./oubliepuis « Comment je m'appelle ? » : il ne sait plus.- Le compteur d'historique augmente de 2 à chaque tour (question + réponse).
Pièges à surveiller
- Oublier d'ajouter la réponse de l'assistant à la liste : le modèle ne voit plus que vos questions et perd le fil.
- Le modèle n'oublie pas « progressivement » : il voit ce qui est dans la liste, ou rien.
Objectif
Décrire un outil au modèle et regarder ce qu'il en fait, sans rien exécuter.
Concept
On ajoute au JSON un champ tools : une liste de schémas (nom, description, paramètres au format JSON Schema). Le modèle a été entraîné à répondre, quand c'est utile, par un message spécial contenant tool_calls au lieu de texte : c'est le tool call.
Le modèle n'exécute rien et ne voit jamais le code Python : seulement le schéma. La description est donc un vrai morceau de prompt, à écrire avec soin. La fonction read_file existe ici, mais personne ne l'appelle encore.
"""Étape 3 — Premier outil (sans l'exécuter).
On décrit une fonction Python au modèle grâce à un schéma JSON envoyé dans
le champ `tools`. Le modèle ne peut pas l'exécuter : il peut seulement
répondre « je voudrais appeler read_file avec path=... ». C'est un tool call.
Ici, on se contente d'afficher cette demande brute.
Lancer (depuis la racine du projet) :
uv run python steps/step3_tool.py
uv run python steps/step3_tool.py "Que contient README.md ?"
"""
import json
import os
import sys
from pathlib import Path
import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://192.168.1.76:11434").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:4b")
def read_file(path: str) -> str:
"""La vraie fonction Python. À cette étape, personne ne l'appelle encore."""
return Path(path).read_text(encoding="utf-8")
# Ce que le modèle voit de l'outil : un nom, une description, des paramètres.
READ_FILE_SCHEMA = {
"type": "function",
"function": {
"name": "read_file",
"description": "Lit un fichier texte et renvoie tout son contenu.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin du fichier, ex. 'pyproject.toml'."}
},
"required": ["path"],
},
},
}
def chat(messages: list[dict], tools: list[dict]) -> dict:
payload = {
"model": OLLAMA_MODEL,
"messages": messages,
"tools": tools, # la liste des outils que le modèle peut demander
"stream": False,
"think": False,
"options": {"temperature": 0.2, "num_ctx": 8192},
}
response = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)
response.raise_for_status()
return response.json()["message"]
def main() -> None:
question = " ".join(sys.argv[1:]) or "Que contient le fichier pyproject.toml ?"
message = chat([{"role": "user", "content": question}], [READ_FILE_SCHEMA])
print("Message brut renvoyé par le modèle :")
print(json.dumps(message, indent=2, ensure_ascii=False))
tool_calls = message.get("tool_calls") or []
if not tool_calls:
print("\nPas d'appel d'outil : le modèle a répondu directement.")
return
for call in tool_calls:
function = call["function"]
print(f"\nLe modèle demande : {function['name']}({function['arguments']})")
print("(À cette étape, on n'exécute rien : le modèle ne peut pas agir seul.)")
if __name__ == "__main__":
main()Lancer
uv run python steps/step3_tool.py uv run python steps/step3_tool.py "Bonjour !"
Ce qu'on doit observer
Un message avec "content": "" et tool_calls contenant "name": "read_file" et "arguments": {"path": "pyproject.toml"} (selon la version d'Ollama, des champs comme id ou index peuvent s'ajouter). Les arguments sont un objet JSON, donc un dict Python, et non une chaîne comme dans l'API OpenAI.
Avec « Bonjour ! », le modèle répond normalement, sans outil.
Pièges à surveiller
- Le modèle invente le contenu du fichier au lieu de demander l'outil.
- Chemin approximatif :
./pyproject.toml, chemin absolu inventé… - Tool call écrit en texte dans
content(par ex.<tool_call>…) au lieu du champtool_calls: à noter si ça arrive, c'est typique des petits modèles.
Objectif
Exécuter les outils demandés et laisser le modèle continuer jusqu'à sa réponse finale.
Concept
C'est le cœur d'un agent. Tant que le modèle demande des outils, on les exécute et on lui renvoie les résultats ; dès qu'il répond sans outil, c'est fini. L'ordre des messages compte :
user "Lis pyproject.toml et donne les dépendances." assistant tool_calls: read_file(path="pyproject.toml") tool tool_name="read_file", content="[project] ..." assistant "La seule dépendance est requests."
Le message de l'assistant (avec ses tool_calls) est ajouté avant les résultats : sinon le modèle verrait des résultats « sortis de nulle part ». Un résultat a le rôle tool et le champ tool_name (format d'Ollama). Un agent, c'est donc un LLM, une boucle et des outils ; le reste est du harness.
"""Étape 4 — La boucle agentique.
Cette fois, on exécute les outils demandés et on renvoie leur résultat :
1. on appelle le modèle avec la conversation et les outils ;
2. s'il demande des outils : on ajoute SON message à la conversation,
on exécute chaque outil, on ajoute chaque résultat (rôle "tool"),
et on recommence en 1 ;
3. sinon, sa réponse est la réponse finale.
Lancer (depuis la racine du projet) :
uv run python steps/step4_loop.py
uv run python steps/step4_loop.py "Lis le pyproject.toml et donne les dépendances."
"""
import os
import sys
from pathlib import Path
import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://192.168.1.76:11434").rstrip("/")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:4b")
MAX_TOURS = 10 # garde-fou minimal contre une boucle infinie (voir étape 6)
def read_file(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
READ_FILE_SCHEMA = {
"type": "function",
"function": {
"name": "read_file",
"description": "Lit un fichier texte et renvoie tout son contenu.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin du fichier, ex. 'pyproject.toml'."}
},
"required": ["path"],
},
},
}
def chat(messages: list[dict], tools: list[dict]) -> dict:
payload = {
"model": OLLAMA_MODEL,
"messages": messages,
"tools": tools,
"stream": False,
"think": False,
"options": {"temperature": 0.2, "num_ctx": 8192},
}
response = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)
response.raise_for_status()
return response.json()["message"]
def run_tool(name: str, arguments: dict) -> str:
"""Exécute l'outil demandé. Une erreur devient un texte pour le modèle."""
if name != "read_file":
return f"Erreur : outil inconnu '{name}'."
try:
return read_file(**arguments)
except Exception as error:
return f"Erreur : {type(error).__name__}: {error}"
def main() -> None:
question = " ".join(sys.argv[1:]) or "Lis pyproject.toml et dis-moi quelles sont les dépendances du projet."
messages = [{"role": "user", "content": question}]
for tour in range(1, MAX_TOURS + 1):
message = chat(messages, [READ_FILE_SCHEMA])
messages.append(message) # 1) le message de l'assistant, avec ses tool_calls
tool_calls = message.get("tool_calls") or []
if not tool_calls:
print(f"\nRéponse finale (tour {tour}) :\n{message['content']}")
return
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"]["arguments"]
print(f"[tour {tour}] le modèle appelle {name}({arguments})")
result = run_tool(name, arguments)
print(f" -> {len(result)} caractères renvoyés au modèle")
# 2) le résultat, avec le rôle "tool" et le nom de l'outil
messages.append({"role": "tool", "tool_name": name, "content": result})
print(f"Arrêt : {MAX_TOURS} tours sans réponse finale.")
if __name__ == "__main__":
main()Lancer
uv run python steps/step4_loop.py uv run python steps/step4_loop.py "Lis le pyproject.toml et donne les dépendances."
Ce qu'on doit observer
[tour 1] le modèle appelle read_file({'path': 'pyproject.toml'})
-> … caractères renvoyés au modèle
Réponse finale (tour 2) :
…Les tests de la boucle (avec un faux client Ollama) arrivent à l'étape suivante, quand la boucle passe dans src/ et reçoit la fonction chat en paramètre.
Pièges à surveiller
- Oublier d'ajouter le message de l'assistant : le modèle ne sait plus qu'il a déjà demandé l'outil et le redemande.
- Un fichier absent ferait planter le script :
run_toolrenvoie donc l'erreur au modèle sous forme de texte, et il peut se corriger. - Sans
MAX_TOURS, un modèle qui redemande sans cesse le même outil ne s'arrêterait jamais.
Objectif
Passer à la version modulaire dans src/llamagent/ et pouvoir ajouter un outil sans jamais toucher à la boucle. Nouveaux outils : list_files, write_file, run_command.
Concept
- Registre : un dictionnaire
TOOLSqui associe un nom à(fonction, schéma). La boucle ne connaît quetool_schemas()etexecute_tool(). Ajouter un outil = une fonction + un schéma + une ligne dans le dictionnaire. - Contexte des outils :
ToolContextregroupe ce dont les outils ont besoin (dossier de travail, fonction de confirmation, dossier des skills, timeout). Le harness le fournit, les outils n'utilisent aucune variable globale : ils sont faciles à tester. - Boucle testable :
run_turnreçoit la fonctionchaten paramètre. En vrai, c'estllm.chat; dans les tests, un faux modèle qui rejoue des réponses écrites à l'avance. - Peu d'outils : 4 ici, 5 avec
load_skillà l'étape 7. Un 4B se perd vite si on lui en donne trop ; chaque description est courte et précise.
Ce sont 5 fichiers complets à créer dans src/llamagent/ : config.py (version finale), llm.py, guards.py, tools.py et agent.py, plus un fichier de tests.
config.py prend la forme qu'il garde jusqu'à l'étape 8 : délai des requêtes, limites des garde-fous (expliquées à l'étape 6), dossier de travail, et emplacement des dossiers prompts/ et skills/ (étape 7). Il remplace la version de l'étape 0 ; la partie 2 y ajoutera les fichiers de réglages.
"""Configuration de llamagent.
Tout ce qui peut changer d'une machine à l'autre (adresse d'Ollama, modèle,
dossier de travail) est lu dans les variables d'environnement, avec des
valeurs par défaut adaptées à mon NAS. Le reste (réglages du modèle, limites
des garde-fous) est fixé ici pour que tout le projet utilise les mêmes valeurs.
"""
import os
from dataclasses import dataclass
from pathlib import Path
# Valeurs par défaut : Ollama sur le NAS, modèle 4B qui tient dans 6 Go de VRAM.
DEFAULT_OLLAMA_URL = "http://192.168.1.76:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
# Température basse : on veut un agent prévisible, pas créatif.
TEMPERATURE = 0.2
# Taille de la fenêtre de contexte (en tokens) demandée à Ollama.
# 8192 est un bon compromis pour un modèle 4B sur une carte de 6 Go.
NUM_CTX = 8192
# Délai maximal d'une requête à Ollama, en secondes. Le premier appel est
# lent car Ollama doit charger le modèle en VRAM.
REQUEST_TIMEOUT = 120
# Garde-fous (étape 6).
MAX_TOURS = 15 # nombre maximal d'appels au LLM pour une même demande
COMMAND_TIMEOUT = 30 # durée maximale d'une commande lancée par run_command (s)
MAX_OUTPUT_CHARS = 4000 # taille maximale d'un résultat d'outil (~1000 tokens)
# Dossiers du projet : prompts/ et skills/ sont à la racine du dépôt,
# deux niveaux au-dessus de ce fichier (src/llamagent/config.py).
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PROMPTS_DIR = PROJECT_ROOT / "prompts"
SKILLS_DIR = PROJECT_ROOT / "skills"
@dataclass(frozen=True)
class Config:
"""Réglages chargés au démarrage (immuables ensuite)."""
ollama_url: str
model: str
workdir: Path # le seul dossier où l'agent a le droit de lire et d'écrire
temperature: float = TEMPERATURE
num_ctx: int = NUM_CTX
@property
def chat_url(self) -> str:
"""URL de l'API native d'Ollama (et non la couche /v1 compatible OpenAI)."""
return f"{self.ollama_url}/api/chat"
def options(self) -> dict:
"""Bloc `options` envoyé à Ollama à chaque appel."""
return {"temperature": self.temperature, "num_ctx": self.num_ctx}
def load_config() -> Config:
"""Construit la configuration à partir des variables d'environnement."""
url = os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL)
model = os.environ.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
# Par défaut, l'agent travaille dans le dossier où on le lance.
workdir = Path(os.environ.get("LLAMAGENT_WORKDIR", Path.cwd())).resolve()
# Un dossier de travail absent ferait échouer tous les outils plus tard,
# avec des erreurs obscures (WinError 267 sous Windows) : on s'arrête net.
if not workdir.is_dir():
raise SystemExit(f"Dossier de travail introuvable : {workdir} (vérifie LLAMAGENT_WORKDIR).")
# On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
return Config(ollama_url=url.rstrip("/"), model=model, workdir=workdir)"""Appel au LLM via l'API native d'Ollama (/api/chat).
C'est le seul module qui parle au réseau. Le reste du projet ne manipule
que des listes de messages (des dictionnaires Python).
"""
import requests
from llamagent.config import REQUEST_TIMEOUT, Config
def chat(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation (et la liste des outils) à Ollama.
Renvoie le message de l'assistant, par exemple :
{"role": "assistant", "content": "Bonjour !"}
ou, si le modèle veut utiliser un outil :
{"role": "assistant", "content": "",
"tool_calls": [{"function": {"name": "read_file",
"arguments": {"path": "main.py"}}}]}
"""
payload = {
"model": config.model,
"messages": messages,
"stream": False,
"think": False,
"options": config.options(),
}
if tools:
payload["tools"] = tools
response = requests.post(config.chat_url, json=payload, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.json()["message"]tools.py importe safe_path et GuardError depuis guards.py. Copie dès maintenant le fichier guards.py donné à l'étape 6 : ses garde-fous y sont expliqués."""Les outils mis à disposition du modèle, et leur registre.
Un outil = une fonction Python + un schéma JSON qui la décrit au modèle.
Le modèle ne voit que le schéma (nom, description, paramètres) ; c'est le
harness qui exécute la fonction quand le modèle la demande.
Pour ajouter un outil : écrire la fonction, écrire son schéma, ajouter une
ligne dans TOOLS. La boucle agentique (agent.py) n'a pas à être modifiée.
"""
import difflib
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from llamagent.config import COMMAND_TIMEOUT
from llamagent.guards import GuardError, safe_path
# Dossiers jamais listés : sans intérêt pour le modèle et souvent énormes.
IGNORED_DIRS = {"__pycache__", "node_modules"}
MAX_LISTED_FILES = 200
@dataclass
class ToolContext:
"""Ce dont les outils ont besoin pour travailler, fourni par le harness."""
workdir: Path # dossier de travail : les outils ne sortent pas d'ici
skills_dir: Path # dossier des skills (fichiers Markdown)
confirm: Callable[[str], bool] # pose une question [o/N] à l'utilisateur
command_timeout: int = COMMAND_TIMEOUT
def read_file(ctx: ToolContext, path: str) -> str:
"""Renvoie le contenu d'un fichier texte du dossier de travail."""
return safe_path(ctx.workdir, path).read_text(encoding="utf-8")
def list_files(ctx: ToolContext, path: str = ".") -> str:
"""Liste les fichiers d'un dossier (et de ses sous-dossiers)."""
root = safe_path(ctx.workdir, path)
# Sans cette vérification, os.walk ignore en silence un dossier absent :
# on répondrait « aucun fichier » et le modèle croirait le dossier vide.
if not root.is_dir():
return f"Erreur : '{path}' n'est pas un dossier existant. Utilise '.' pour le dossier de travail."
workdir = ctx.workdir.resolve()
files = []
for dirpath, dirnames, filenames in os.walk(root):
# On modifie `dirnames` sur place pour qu'os.walk n'entre pas dans
# les dossiers cachés (.git, .venv...) ni dans les dossiers ignorés.
dirnames[:] = sorted(
d for d in dirnames if not d.startswith(".") and d not in IGNORED_DIRS
)
for name in sorted(filenames):
# as_posix() : toujours des "/" même sous Windows, pour le modèle.
files.append((Path(dirpath) / name).relative_to(workdir).as_posix())
if not files:
return "(aucun fichier)"
if len(files) > MAX_LISTED_FILES:
hidden = len(files) - MAX_LISTED_FILES
files = files[:MAX_LISTED_FILES] + [f"... et {hidden} autres fichiers"]
return "\n".join(files)
def write_file(ctx: ToolContext, path: str, content: str) -> str:
"""Remplace (ou crée) un fichier, après confirmation de l'utilisateur."""
target = safe_path(ctx.workdir, path)
old = target.read_text(encoding="utf-8") if target.exists() else ""
# On montre à l'utilisateur exactement ce qui va changer.
diff = "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile=f"{path} (avant)",
tofile=f"{path} (après)",
)
)
if not ctx.confirm(f"{diff or '(aucun changement)'}\nÉcrire {path} ?"):
return "Refusé par l'utilisateur : le fichier n'a pas été modifié."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Fichier {path} écrit ({len(content.splitlines())} lignes)."
def run_command(ctx: ToolContext, command: str) -> str:
"""Lance une commande shell dans le dossier de travail, après confirmation."""
if not ctx.confirm(f"Exécuter : {command}"):
return "Refusé par l'utilisateur : la commande n'a pas été exécutée."
# Force Python (s'il est lancé par la commande) à écrire en UTF-8,
# sinon Windows utilise cp1252 et les accents deviennent illisibles.
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
try:
result = subprocess.run(
command,
shell=True, # cmd.exe sous Windows, /bin/sh sous macOS
cwd=ctx.workdir,
capture_output=True,
stdin=subprocess.DEVNULL, # une commande ne peut pas attendre une saisie au clavier
encoding="utf-8",
errors="replace",
timeout=ctx.command_timeout,
env=env,
)
except subprocess.TimeoutExpired:
return f"Erreur : la commande a dépassé {ctx.command_timeout} s et a été arrêtée."
return (
f"code de sortie : {result.returncode}\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
def schema(name: str, description: str, properties: dict, required: list[str]) -> dict:
"""Construit le schéma d'un outil au format attendu par Ollama."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {"type": "object", "properties": properties, "required": required},
},
}
PATH_PARAM = {"type": "string", "description": "Chemin relatif au dossier de travail, ex. 'src/main.py'."}
# nom de l'outil -> (fonction Python, schéma envoyé au modèle)
TOOLS: dict[str, tuple[Callable[..., str], dict]] = {
"read_file": (
read_file,
schema("read_file", "Lit un fichier texte et renvoie tout son contenu.", {"path": PATH_PARAM}, ["path"]),
),
"list_files": (
list_files,
schema(
"list_files",
"Liste les fichiers d'un dossier et de ses sous-dossiers. Utilise '.' pour le dossier de travail.",
{"path": PATH_PARAM},
[],
),
),
"write_file": (
write_file,
schema(
"write_file",
"Écrit un fichier texte. Remplace TOUT le contenu : donne le fichier complet, pas seulement la partie modifiée.",
{"path": PATH_PARAM, "content": {"type": "string", "description": "Contenu complet du fichier."}},
["path", "content"],
),
),
"run_command": (
run_command,
schema(
"run_command",
"Exécute une commande shell dans le dossier de travail et renvoie sa sortie. Ex. 'python script.py'.",
{"command": {"type": "string", "description": "La commande à exécuter."}},
["command"],
),
),
}
def tool_schemas() -> list[dict]:
"""Les schémas de tous les outils, à envoyer au modèle."""
return [tool_schema for _, tool_schema in TOOLS.values()]
def execute_tool(name: str, arguments: dict, ctx: ToolContext) -> str:
"""Version simple de l'étape 5 (l'étape 8 y ajoute la gestion des erreurs)."""
function, _ = TOOLS[name]
return function(ctx, **arguments)L'entrée "load_skill" du registre s'ajoute à l'étape 7, et execute_tool sera enrichie à l'étape 8.
"""La boucle agentique (version de l'étape 5)."""
from typing import Callable
from llamagent.config import MAX_TOURS
from llamagent.tools import ToolContext, execute_tool, tool_schemas
# Une fonction qui reçoit (messages, schémas des outils) et renvoie le message
# de l'assistant. En vrai : llm.chat. Dans les tests : un faux modèle.
ChatFunction = Callable[[list[dict], list[dict]], dict]
def run_turn(chat: ChatFunction, messages: list[dict], ctx: ToolContext, max_tours: int = MAX_TOURS) -> str:
"""Version simple de l'étape 5 (les étapes 6 et 8 l'enrichissent)."""
for tour in range(1, max_tours + 1):
reply = chat(messages, tool_schemas())
messages.append(reply)
tool_calls = reply.get("tool_calls") or []
if not tool_calls:
return reply.get("content", "")
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"].get("arguments", {})
result = execute_tool(name, arguments, ctx)
messages.append({"role": "tool", "tool_name": name, "content": result})
return f"Arrêt : limite de {max_tours} tours atteinte sans réponse finale."Tests
On ajoute pytest comme dépendance de développement (uv add --dev pytest), puis on teste la boucle avec un faux modèle :
"""Tests de la boucle agentique (agent.py), avec un faux modèle."""
import pytest
from llamagent.agent import run_turn
from llamagent.tools import ToolContext
class FakeChat:
"""Faux client Ollama : renvoie des réponses écrites à l'avance."""
def __init__(self, replies):
self.replies = list(replies)
self.calls = [] # ce que la boucle a envoyé à chaque appel
def __call__(self, messages, tools):
self.calls.append(list(messages))
return self.replies.pop(0)
def tool_call(name, **arguments):
return {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": name, "arguments": arguments}}]}
def answer(text):
return {"role": "assistant", "content": text}
@pytest.fixture
def ctx(tmp_path):
(tmp_path / "notes.txt").write_text("12, 15, 9", encoding="utf-8")
return ToolContext(workdir=tmp_path, skills_dir=tmp_path / "skills", confirm=lambda question: True)
def test_reponse_directe_sans_outil(ctx):
chat = FakeChat([answer("Bonjour !")])
messages = [{"role": "user", "content": "Salut"}]
assert run_turn(chat, messages, ctx) == "Bonjour !"
assert len(chat.calls) == 1
def test_outil_execute_et_resultat_renvoye(ctx):
chat = FakeChat([tool_call("read_file", path="notes.txt"), answer("Il y a 3 notes.")])
messages = [{"role": "user", "content": "Combien de notes ?"}]
assert run_turn(chat, messages, ctx) == "Il y a 3 notes."
# La conversation contient : user, assistant (tool_calls), tool, assistant.
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
assert messages[2] == {"role": "tool", "tool_name": "read_file", "content": "12, 15, 9"}
# Au 2e appel, le modèle a bien reçu le résultat de l'outil.
assert chat.calls[1][-1]["content"] == "12, 15, 9"Lancer
Un petit script d'essai à la racine du projet, en attendant la CLI de l'étape 7 :
"""Essai rapide de l'étape 5, à lancer depuis la racine du projet :
uv run python try_step5.py
"""
from llamagent import llm
from llamagent.agent import run_turn
from llamagent.config import SKILLS_DIR, load_config
from llamagent.tools import ToolContext
config = load_config()
ctx = ToolContext(
workdir=config.workdir,
skills_dir=SKILLS_DIR,
confirm=lambda question: input(f"{question} [o/N] ") == "o",
)
messages = [{"role": "user", "content": "Liste les fichiers du projet, puis lis pyproject.toml et donne ses dépendances."}]
answer = run_turn(lambda msgs, tools: llm.chat(config, msgs, tools), messages, ctx)
# La boucle de l'étape 5 n'affiche rien : on relit la conversation pour voir
# quels outils le modèle a appelés et ce qu'il a reçu en retour.
print(f"Dossier de travail : {config.workdir}\n")
for message in messages:
for call in message.get("tool_calls") or []:
print(f"appel : {call['function']['name']} {call['function']['arguments']}")
if message["role"] == "tool":
print(f"résultat : {message['content'][:300]}\n")
print(f"Réponse : {answer}")Toujours depuis la racine du projet (le dossier qui contient pyproject.toml) : pytest ne cherche les tests que sous le dossier courant. Lancé depuis src/llamagent/, il affiche collected 0 items.
uv run python try_step5.py uv run pytest -q
Ce qu'on doit observer
Un appel à list_files, puis à read_file sur pyproject.toml, puis une réponse qui cite requests. Les tests passent.
Pièges à surveiller
- L'agent a répondu « le dossier de travail est vide ». Pour un dossier qui n'existe pas,
os.walkne signale aucune erreur :list_filesrépondait « (aucun fichier) » et le modèle l'a cru.list_filesrenvoie maintenant une erreur explicite, etload_configrefuse au démarrage un dossier de travail inexistant. - Pour lister les fichiers, qwen3.5:4b a préféré
run_command("ls -la")à l'outillist_files. Après un refus, il a proposéls -la && cat pyproject.toml. Ces commandes Unix n'existent pas danscmd.exesous Windows : le prompt système de l'étape 7 indique le shell au modèle et lui demande d'utiliser les outils. - Sous Windows, un dossier de travail inexistant fait échouer
run_commandavecNotADirectoryError: [WinError 267]: c'est lecwddesubprocess.runqui est invalide. À l'étape 5,execute_toolne rattrape pas encore les exceptions : l'agent plante (corrigé à l'étape 8). try_step5.pyaffiche maintenant le dossier de travail et chaque appel d'outil : sans ça, impossible de savoir ce que le modèle a demandé.
- Le modèle appelle
read_filesur un dossier, oulist_filessur un fichier : l'erreur lui est renvoyée. write_fileavec seulement la partie modifiée : le reste du fichier serait perdu. D'où « Remplace TOUT le contenu » dans la description et le diff montré avant écriture.- Windows :
run_commandpasse parcmd.exe, oùlsetcatn'existent pas. Le prompt système de l'étape 7 indique le shell au modèle. - Un programme qui attend une saisie (
input(),pythonsans argument) bloquerait le terminal :stdin=subprocess.DEVNULLlui envoie « fin de fichier ».
Objectif
Protéger l'utilisateur contre les erreurs du modèle, sans compter sur le modèle pour se limiter lui-même.
Concept
Un garde-fou est une règle appliquée par le harness, en Python. Le prompt peut demander au modèle d'être prudent, mais seul le code garantit la limite. Chaque refus est renvoyé au modèle sous forme de texte (Refusé : ...) pour qu'il puisse s'adapter.
- Confirmation
[o/N]avantwrite_file(avec le diff) etrun_command. Par défaut, Entrée = non. - Dossier de travail :
safe_pathrefuse..et tout chemin absolu hors du dossier.resolve()suit aussi les liens symboliques. - MAX_TOURS : 15 appels au LLM au maximum par demande.
- Timeout : 30 s par commande.
- Troncature : 4000 caractères par résultat d'outil (environ 1000 tokens), en gardant le début et la fin, car la fin contient souvent l'erreur.
"""Garde-fous : ce qui protège l'utilisateur contre les erreurs du modèle.
Le modèle peut se tromper de chemin, vouloir écrire n'importe où ou lancer
une commande qui ne s'arrête jamais. Ces vérifications sont faites par le
harness, en Python : on ne compte pas sur le modèle pour les respecter.
"""
from pathlib import Path
class GuardError(Exception):
"""Action refusée par un garde-fou. Le message est renvoyé au modèle."""
def safe_path(workdir: Path, path: str) -> Path:
"""Transforme un chemin donné par le modèle en chemin sûr.
Refuse tout chemin qui contient ".." ou qui sort du dossier de travail
(par exemple un chemin absolu comme /etc/passwd ou C:\\Windows).
"""
if ".." in Path(path).parts:
raise GuardError(f"chemin refusé ({path}) : '..' est interdit.")
root = workdir.resolve()
# Si `path` est absolu, `root / path` vaut `path` : d'où la vérification.
full = (root / path).resolve()
if not full.is_relative_to(root):
raise GuardError(f"chemin refusé ({path}) : il sort du dossier de travail {root}.")
return full
def ask_confirmation(question: str) -> bool:
"""Demande une confirmation à l'utilisateur. Par défaut (Entrée) : non."""
answer = input(f"{question} [o/N] ").strip().lower()
return answer in ("o", "oui")
def truncate(text: str, max_chars: int) -> str:
"""Raccourcit un texte trop long en gardant le début et la fin.
La fin est souvent la partie utile (message d'erreur, traceback).
"""
if len(text) <= max_chars:
return text
half = max_chars // 2
removed = len(text) - 2 * half
return f"{text[:half]}\n[... {removed} caractères tronqués ...]\n{text[-half:]}"# Garde-fous (étape 6).
MAX_TOURS = 15 # nombre maximal d'appels au LLM pour une même demande
COMMAND_TIMEOUT = 30 # durée maximale d'une commande lancée par run_command (s)
MAX_OUTPUT_CHARS = 4000 # taille maximale d'un résultat d'outil (~1000 tokens)
def load_config() -> Config:
"""Construit la configuration à partir des variables d'environnement."""
url = os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL)
model = os.environ.get("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
# Par défaut, l'agent travaille dans le dossier où on le lance.
workdir = Path(os.environ.get("LLAMAGENT_WORKDIR", Path.cwd())).resolve()
# Un dossier de travail absent ferait échouer tous les outils plus tard,
# avec des erreurs obscures (WinError 267 sous Windows) : on s'arrête net.
if not workdir.is_dir():
raise SystemExit(f"Dossier de travail introuvable : {workdir} (vérifie LLAMAGENT_WORKDIR).")
# On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
return Config(ollama_url=url.rstrip("/"), model=model, workdir=workdir)Dans agent.py, on complète les imports, puis une ligne suffit pour la troncature :
from llamagent.config import MAX_OUTPUT_CHARS, MAX_TOURS from llamagent.guards import truncate
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"].get("arguments", {})
result = execute_tool(name, arguments, ctx)
result = truncate(result, MAX_OUTPUT_CHARS) # garde-fou : résultat tronqué
messages.append({"role": "tool", "tool_name": name, "content": result})Et dans try_step5.py, le contexte des outils utilise la vraie confirmation :
from llamagent.guards import ask_confirmation ctx = ToolContext(workdir=config.workdir, skills_dir=SKILLS_DIR, confirm=ask_confirmation)
"""Tests des garde-fous (guards.py)."""
import pytest
from llamagent.guards import GuardError, ask_confirmation, safe_path, truncate
def test_chemin_relatif_accepte(tmp_path):
assert safe_path(tmp_path, "src/main.py") == (tmp_path / "src" / "main.py").resolve()
def test_point_point_refuse(tmp_path):
with pytest.raises(GuardError, match=r"\.\."):
safe_path(tmp_path, "../secret.txt")
def test_point_point_refuse_meme_s_il_reste_dedans(tmp_path):
with pytest.raises(GuardError):
safe_path(tmp_path, "src/../main.py")
def test_chemin_absolu_hors_du_dossier_refuse(tmp_path):
dehors = tmp_path.parent / "ailleurs.txt"
with pytest.raises(GuardError, match="sort du dossier"):
safe_path(tmp_path, str(dehors))
def test_chemin_absolu_dans_le_dossier_accepte(tmp_path):
dedans = tmp_path / "main.py"
assert safe_path(tmp_path, str(dedans)) == dedans.resolve()
def test_truncate_texte_court_inchange():
assert truncate("bonjour", 100) == "bonjour"
def test_truncate_garde_debut_et_fin():
texte = "A" * 50 + "B" * 50 + "C" * 50
resultat = truncate(texte, 40)
assert resultat.startswith("A" * 20)
assert resultat.endswith("C" * 20)
assert "110 caractères tronqués" in resultat
@pytest.mark.parametrize(
("reponse", "attendu"),
[("o", True), ("oui", True), ("O", True), ("", False), ("n", False), ("non", False), ("y", False)],
)
def test_confirmation(monkeypatch, reponse, attendu):
monkeypatch.setattr("builtins.input", lambda question: reponse)
assert ask_confirmation("Continuer ?") is attenduCe qu'on doit observer
- « Lis ../README.md » : le modèle reçoit
Refusé : chemin refusé (../README.md) : '..' est interdit.. - « Crée hello.py qui affiche bonjour, puis lance-le » : le diff s'affiche, puis
Écrire hello.py ? [o/N]. Si on répond N, le modèle reçoit le refus et doit le dire. uv run pytest -q: tous les tests passent.
Pièges à surveiller
- Sous Windows, un chemin comme
C:\Users\...est absolu : refusé s'il sort du dossier de travail. - La troncature peut couper l'information utile au milieu d'un gros fichier : le modèle doit alors travailler sur de plus petits fichiers.
- La confirmation rend l'agent moins autonome : c'est voulu, on garde la main.
Objectif
Donner des consignes stables à l'agent (prompt système) et des fiches de méthode qu'il charge à la demande (skills).
Concept
Le prompt système est le premier message de la conversation. Il est renvoyé à chaque appel et occupe donc du contexte en permanence : il doit rester court. Il vit dans prompts/AGENT.md (modifiable sans toucher au code), avec des marqueurs {os}, {shell}, {workdir} et {skills} remplis au démarrage.
Une skill est une fiche Markdown dans skills/. Le prompt système n'en contient que le nom et une ligne de description ; le contenu complet n'entre dans le contexte que si le modèle appelle load_skill. C'est le principe de « divulgation progressive », le même que les skills de Claude Code (un fichier avec un en-tête description:).
Tu es llamagent, un assistant de programmation qui travaille dans un dossier de projet.
## Règles
- Utilise les outils pour lire et modifier les fichiers. N'invente jamais le contenu d'un fichier : lis-le.
- Avant de modifier un fichier, lis-le avec read_file.
- write_file remplace TOUT le fichier : donne toujours le contenu complet.
- Les chemins sont relatifs au dossier de travail.
- Après une correction, vérifie-la avec run_command quand c'est possible.
- N'utilise que les outils fournis.
- Quand la tâche est terminée, réponds sans appeler d'outil et explique en quelques phrases ce que tu as fait.
- Réponds en français.
## Environnement
- Système : {os}
- Les commandes sont exécutées par : {shell}
- Dossier de travail : {workdir}
- Pour lancer Python, utilise `python` (exemples : `python script.py`, `python -m pytest`).
## Skills
Une skill est une fiche d'instructions pour un type de tâche. Si la demande correspond à une skill, charge-la d'abord avec load_skill, puis suis ses instructions.
{skills}--- description: Méthode pour trouver et corriger un bug dans un script Python. --- # Corriger un bug 1. Lance le script avec run_command pour voir l'erreur ou le résultat faux. 2. Lis le fichier concerné avec read_file. 3. Trouve la cause exacte : la ligne fautive et pourquoi elle est fausse. 4. Corrige avec write_file en changeant le moins de choses possible. Donne le fichier complet, sans rien supprimer d'autre. 5. Relance le script avec run_command pour vérifier que le bug a disparu. 6. Réponds en expliquant : la cause du bug, la correction, la vérification.
--- description: Écrire des tests pytest pour un module Python. --- # Écrire des tests pytest 1. Lis le module à tester avec read_file. 2. Crée un fichier `test_<module>.py` à côté du module avec write_file. 3. Écris une fonction `test_...` par comportement : un cas normal, un cas limite (liste vide, zéro...), un cas d'erreur avec `pytest.raises`. 4. Importe le module avec `from <module> import <fonction>`. 5. Lance `python -m pytest -q` avec run_command. 6. Si un test échoue, vérifie d'abord si c'est le test ou le code qui est faux. Ne modifie pas le code testé sans le dire. 7. Réponds avec la liste des tests écrits et le résultat de pytest.
Trois fonctions à placer dans tools.py avant le dictionnaire TOOLS, qui y fait référence :
def load_skill(ctx: ToolContext, name: str) -> str:
"""Renvoie le contenu d'une skill (fichier Markdown de skills/)."""
skills = available_skills(ctx.skills_dir)
# On vérifie le nom dans la liste : impossible de lire un autre fichier.
if name not in skills:
raise GuardError(f"skill inconnue '{name}'. Skills disponibles : {', '.join(skills) or 'aucune'}.")
return (ctx.skills_dir / f"{name}.md").read_text(encoding="utf-8")
def read_skill_description(path: Path) -> str:
"""Lit la ligne `description:` de l'en-tête d'une skill.
Une skill commence par un petit en-tête entre deux lignes "---" :
---
description: Écrire des tests pytest pour du code Python.
---
"""
lines = path.read_text(encoding="utf-8").splitlines()
if lines and lines[0].strip() == "---":
for line in lines[1:]:
if line.strip() == "---":
break
key, _, value = line.partition(":")
if key.strip() == "description":
return value.strip()
return "(pas de description)"
def available_skills(skills_dir: Path) -> dict[str, str]:
"""Renvoie {nom de la skill: description} pour chaque fichier .md."""
if not skills_dir.is_dir():
return {}
return {p.stem: read_skill_description(p) for p in sorted(skills_dir.glob("*.md"))}Dans le dictionnaire TOOLS, on ajoute l'entrée de load_skill (5e et dernier outil) :
"load_skill": (
load_skill,
schema(
"load_skill",
"Charge les instructions d'une skill listée dans le prompt système.",
{"name": {"type": "string", "description": "Nom exact de la skill, ex. 'corriger-un-bug'."}},
["name"],
),
),Le prompt est rempli par build_system_prompt, dans agent.py, qui a désormais ses imports définitifs :
"""La boucle agentique : le cœur de llamagent.
tant que le modèle demande des outils :
exécuter chaque outil demandé
ajouter le résultat à la conversation
rappeler le modèle
sinon : renvoyer sa réponse finale
La boucle ne connaît aucun outil en particulier : elle passe par le registre
de tools.py. Elle ne parle pas non plus directement à Ollama : on lui donne
une fonction `chat`, ce qui permet de la tester avec un faux modèle.
"""
import json
import os
import platform
from pathlib import Path
from typing import Callable
from llamagent.config import MAX_OUTPUT_CHARS, MAX_TOURS
from llamagent.guards import truncate
from llamagent.tools import ToolContext, execute_tool, tool_schemasdef build_system_prompt(template: str, workdir: Path, skills: dict[str, str]) -> str:
"""Remplit le modèle de prompt (prompts/AGENT.md) avec l'environnement réel."""
shell = "cmd.exe" if os.name == "nt" else "/bin/sh"
skills_text = "\n".join(f"- {name} : {description}" for name, description in skills.items())
# str.replace plutôt que str.format : le Markdown peut contenir des { }.
return (
template.replace("{os}", platform.system())
.replace("{shell}", shell)
.replace("{workdir}", str(workdir))
.replace("{skills}", skills_text or "(aucune)")
)La CLI assemble enfin tout le harness :
"""Point d'entrée de la ligne de commande : `uv run llamagent`.
uv run llamagent # mode conversation
uv run llamagent "corrige le bug..." # une seule demande, puis on quitte
C'est ici qu'on assemble le harness : configuration, prompt système,
outils, garde-fous, puis la boucle agentique.
"""
import os
import sys
import requests
from llamagent import llm
from llamagent.agent import build_system_prompt, run_turn
from llamagent.config import PROMPTS_DIR, SKILLS_DIR, load_config
from llamagent.guards import ask_confirmation
from llamagent.tools import ToolContext, available_skills
def ask_agent(chat, messages: list[dict], ctx: ToolContext, text: str) -> None:
"""Ajoute une demande à la conversation, lance la boucle, affiche la réponse."""
messages.append({"role": "user", "content": text})
try:
answer = run_turn(chat, messages, ctx)
except requests.RequestException as error:
print(f"Erreur de communication avec Ollama : {error}")
return
print(f"\nagent > {answer}")
def main() -> None:
if os.name == "nt":
os.system("") # active les couleurs ANSI dans les anciennes consoles Windows
config = load_config()
template = (PROMPTS_DIR / "AGENT.md").read_text(encoding="utf-8")
skills = available_skills(SKILLS_DIR)
ctx = ToolContext(workdir=config.workdir, skills_dir=SKILLS_DIR, confirm=ask_confirmation)
# La conversation commence toujours par le prompt système.
messages = [{"role": "system", "content": build_system_prompt(template, config.workdir, skills)}]
def chat(msgs: list[dict], tools: list[dict]) -> dict:
return llm.chat(config, msgs, tools)
print(f"llamagent — modèle {config.model} sur {config.ollama_url}")
print(f"Dossier de travail : {config.workdir}")
try:
# Mode « une seule demande » : la demande est passée en argument.
if len(sys.argv) > 1:
ask_agent(chat, messages, ctx, " ".join(sys.argv[1:]))
return
print("Tape ta demande (/quit pour sortir).")
while True:
text = input("\nvous > ").strip()
if text in ("/quit", "/exit"):
break
if text:
ask_agent(chat, messages, ctx, text)
except (KeyboardInterrupt, EOFError):
print("\nAu revoir.")
if __name__ == "__main__":
main()Lancer
uv run llamagent uv run llamagent "Écris des tests pytest pour demo/stats.py"
Ce qu'on doit observer
L'en-tête (modèle, dossier de travail), puis, pour la demande de tests, un premier appel load_skill {"name": "ecrire-tests-pytest"} si le modèle suit la consigne, suivi des étapes de la fiche.
Pièges à surveiller
- Un 4B ne charge pas toujours la skill de lui-même : citer son nom dans la demande aide. À noter : charge-t-il la skill sans qu'on la nomme ?
- Nom de skill inventé ou mal orthographié :
load_skillrenvoie la liste des skills valides. - Plus le prompt système est long, moins il reste de place pour le code dans les 8192 tokens.
- Les dossiers
prompts/etskills/sont trouvés à partir de l'emplacement deconfig.py: ça marche avec l'installation éditable deuv sync, pas avec un wheel installé ailleurs.
Objectif
Un agent qui ne plante jamais à cause du modèle, des logs lisibles, et une démo de bout en bout.
Concept
- Toute erreur devient un texte pour le modèle : outil inconnu (avec la liste des outils valides), argument manquant ou inconnu, arguments en JSON invalide, exception Python. Le modèle lit l'erreur au tour suivant et peut se corriger.
- Détection de boucle : si le modèle refait exactement le même appel deux fois de suite, on ne l'exécute pas et on lui dit de changer d'approche.
- Logs colorés : chaque appel d'outil est affiché (tour, nom, arguments) avec un aperçu du résultat, en rouge si c'est une erreur. Codes ANSI, désactivables avec
NO_COLOR=1.
def execute_tool(name: str, arguments, ctx: ToolContext) -> str:
"""Exécute un outil demandé par le modèle et renvoie toujours un texte.
Aucune erreur ne doit faire planter l'agent : chaque problème est
décrit dans le texte renvoyé, pour que le modèle puisse se corriger.
"""
if name not in TOOLS:
return f"Erreur : outil inconnu '{name}'. Outils valides : {', '.join(TOOLS)}."
function, tool_schema = TOOLS[name]
# Certains modèles renvoient les arguments sous forme de texte JSON.
if isinstance(arguments, str):
try:
arguments = json.loads(arguments or "{}")
except json.JSONDecodeError:
return f"Erreur : les arguments de {name} ne sont pas du JSON valide : {arguments}"
if not isinstance(arguments, dict):
return f"Erreur : les arguments de {name} doivent être un objet JSON."
parameters = tool_schema["function"]["parameters"]
expected = ", ".join(parameters["properties"])
missing = [p for p in parameters["required"] if p not in arguments]
if missing:
return f"Erreur : argument manquant pour {name} : {', '.join(missing)}. Paramètres attendus : {expected}."
unknown = [p for p in arguments if p not in parameters["properties"]]
if unknown:
return f"Erreur : argument inconnu pour {name} : {', '.join(unknown)}. Paramètres attendus : {expected}."
try:
return function(ctx, **arguments)
except GuardError as error:
return f"Refusé : {error}"
except Exception as error: # toute autre exception Python
return f"Erreur pendant {name} : {type(error).__name__}: {error}"# Codes couleur ANSI. NO_COLOR=1 les désactive (convention https://no-color.org).
USE_COLOR = "NO_COLOR" not in os.environ
GREY, RED, YELLOW, CYAN = "90", "31", "33", "36"
def color(text: str, code: str) -> str:
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
def log_tool_call(tour: int, name: str, arguments) -> None:
args = json.dumps(arguments, ensure_ascii=False)
if len(args) > 120:
args = args[:120] + "…"
print(color(f"[tour {tour}] ", GREY) + color(f"{name}", CYAN) + color(f" {args}", GREY))
def log_tool_result(result: str) -> None:
is_error = result.startswith(("Erreur", "Refusé"))
lines = result.splitlines() or [""]
preview = lines[0][:100] + (f" (+{len(lines) - 1} lignes)" if len(lines) > 1 else "")
print(color(f" → {preview}", RED if is_error else GREY))def run_turn(chat: ChatFunction, messages: list[dict], ctx: ToolContext, max_tours: int = MAX_TOURS) -> str:
"""Traite le dernier message utilisateur et renvoie la réponse finale.
`messages` est modifiée sur place : on y ajoute les messages de
l'assistant et les résultats des outils, pour garder tout l'historique.
"""
previous_call = None
for tour in range(1, max_tours + 1):
reply = chat(messages, tool_schemas())
# 1) On garde le message de l'assistant TEL QUEL, avec ses tool_calls :
# au prochain appel, le modèle doit voir ce qu'il a demandé.
messages.append(reply)
tool_calls = reply.get("tool_calls") or []
if not tool_calls:
return reply.get("content", "")
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"].get("arguments", {})
log_tool_call(tour, name, arguments)
# Détection de boucle : le même appel deux fois de suite ne
# donnera pas un résultat différent, on le signale au modèle.
signature = (name, json.dumps(arguments, sort_keys=True, ensure_ascii=False))
if signature == previous_call:
result = (
f"Erreur : tu viens déjà d'appeler {name} avec exactement ces arguments. "
"Le résultat sera le même : change d'approche ou donne ta réponse finale."
)
else:
result = execute_tool(name, arguments, ctx)
previous_call = signature
result = truncate(result, MAX_OUTPUT_CHARS)
log_tool_result(result)
# 2) Puis le résultat de l'outil, avec le rôle "tool".
messages.append({"role": "tool", "tool_name": name, "content": result})
return f"Arrêt : limite de {max_tours} tours atteinte sans réponse finale."La démo
"""Petit script de statistiques sur des notes.
Il contient un bug volontaire : c'est la démo de llamagent.
"""
def moyenne(notes: list[float]) -> float:
total = 0
for i in range(1, len(notes)):
total += notes[i]
return total / len(notes)
if __name__ == "__main__":
notes = [12, 15, 9, 18]
resultat = moyenne(notes)
print(f"Moyenne des notes {notes} : {resultat}")
assert resultat == 13.5, f"moyenne attendue : 13.5, obtenue : {resultat}"
print("OK")cd demo uv run llamagent "Trouve le bug dans stats.py et corrige-le."
Lancé depuis demo/, l'agent a ce dossier comme dossier de travail : il ne peut pas modifier son propre code.
Ce qu'on doit observer
Le déroulé idéal ressemble à ceci. Cette sortie a été obtenue avec le vrai harness mais un faux modèle qui rejoue ce scénario, pour vérifier les logs, les confirmations et la correction. Le vrai qwen3.5:4b peut s'en écarter, et c'est justement ce qu'il faut observer.
llamagent — modèle qwen3.5:4b sur http://127.0.0.1:8772
Dossier de travail : …/llamagent/demo
[tour 1] load_skill {"name": "corriger-un-bug"}
→ --- (+11 lignes)
[tour 2] run_command {"command": "python stats.py"}
Exécuter : python stats.py [o/N] o
→ code de sortie : 1 (+9 lignes)
[tour 3] read_file {"path": "stats.py"}
→ """Petit script de statistiques sur des notes. (+18 lignes)
[tour 4] write_file {"path": "stats.py", "content": "\"\"\"Petit script de statistiques sur des notes.\n\nIl contient un bug volontaire : c'…
--- stats.py (avant)
+++ stats.py (après)
@@ -6,7 +6,7 @@
def moyenne(notes: list[float]) -> float:
total = 0
- for i in range(1, len(notes)):
+ for i in range(len(notes)):
total += notes[i]
return total / len(notes)
Écrire stats.py ? [o/N] o
→ Fichier stats.py écrit (19 lignes).
[tour 5] run_command {"command": "python stats.py"}
Exécuter : python stats.py [o/N] o
→ code de sortie : 0 (+5 lignes)
agent > La boucle commençait à l'indice 1 : la première note était ignorée. Corrigé, le script affiche OK.Voici ce que le modèle reçoit en résultat du premier run_command : la sortie et l'erreur, qui lui donnent la piste.
code de sortie : 1
--- stdout ---
Moyenne des notes [12, 15, 9, 18] : 10.5
--- stderr ---
Traceback (most recent call last):
File "…/llamagent/demo/stats.py", line 18, in <module>
assert resultat == 13.5, f"moyenne attendue : 13.5, obtenue : {resultat}"
^^^^^^^^^^^^^^^^
AssertionError: moyenne attendue : 13.5, obtenue : 10.5Les tests de tous les cas d'erreur sont dans tests/test_tools.py (voir le code complet). Pour rejouer la démo : git checkout demo/stats.py.
Pièges à surveiller
- Outil inventé (
search_web,edit_file…) : est-ce que la liste des outils valides suffit à le remettre sur les rails ? - Correction partielle :
write_fileavec seulement la fonction modifiée. Le diff le montre avant d'écrire : refuser avec N. - Le modèle « corrige » le test ou l'
assertau lieu du bug. - Sous Windows, accents mal affichés dans la sortie d'une commande :
PYTHONIOENCODING=utf-8est forcé pour les scripts Python, mais pas pour les autres programmes. - Sur une longue session, le prompt système peut sortir des 8192 tokens et le modèle oublie ses consignes : relancer l'agent.
Partie 2 — vers un agent du quotidien, comme opencode
Les étapes 9 à 18 transforment llamagent en outil qu'on lance dans n'importe quel projet : consignes et skills du projet, commandes /…, fichiers tagués, réglages persistants, édition ciblée, commandes autorisées, sauvegarde des conversations, compactage du contexte, interface soignée et publication sur PyPI.
config.py, tools.py, session.py, cli.py), et certains extraits utilisent une fonction d'une étape suivante. Le plus sûr : remplacer d'abord src/, prompts/, skills/ et tests/ par le code complet (version finale), lancer uv run pytest -q, puis suivre chaque étape pour comprendre et essayer la fonctionnalité. Les extraits des étapes 9 à 16 montrent le code tel qu'il était à la fin de l'étape 16 ; l'étape 17 change l'affichage, et l'étape 18 prépare la publication (les dossiers prompts/ et skills/ passent dans src/llamagent/).Objectif
Taper llamagent dans n'importe quel dossier, comme opencode ou Continue, avec un agent limité à ce dossier, et des consignes et des skills propres à chaque projet.
Concept
Jusqu'ici, on lançait uv run llamagent depuis le dépôt de llamagent. uv tool install installe la commande llamagent dans un environnement à part et la rend disponible partout. Avec -e (installation éditable), le code reste celui du dépôt : une modification est prise en compte sans réinstaller, et prompts/ et skills/ sont retrouvés à partir de config.py.
- Le dossier où l'on lance llamagent devient le dossier de travail (c'est le cas depuis l'étape 6) :
read_file,list_filesetedit_filepassent parsafe_pathet ne peuvent ni lire ni écrire ailleurs. - AGENTS.md : un fichier Markdown à la racine du projet, ajouté à la fin du prompt système. C'est la convention d'opencode et de Codex : on y écrit ce que l'agent doit savoir du projet (commandes de test, style de code, dossiers à ne pas toucher).
- Skills du projet : dans
.llamagent/skills/. Elles s'ajoutent aux skills globales du dépôt llamagent ; à nom égal, celle du projet l'emporte.
cd C:\Users\nilsm\Documents\llamagent uv tool install -e . uv tool update-shell
uv tool update-shell ajoute le dossier des commandes uv au PATH : à faire une fois, puis rouvrir le terminal. Ensuite, dans n'importe quel projet :
cd C:\chemin\vers\mon-projet llamagent
mon-projet/
├── AGENTS.md # consignes du projet, ajoutées au prompt système
└── .llamagent/
├── .gitignore # exclut sessions/ de git (étape 15)
├── config.toml # réglages du projet (étape 12)
├── sessions/ # conversations sauvegardées (étape 15)
└── skills/ # skills du projet
└── deployer.mdAGENTS.md et le dossier .llamagent/ se créent dans chaque projet où l'on lance llamagent, et non dans le dépôt de llamagent (qui garde, lui, les skills globales de skills/). Le plus simple : la commande /init (étape 10). Ou à la main :# Windows (PowerShell), dans le dossier du projet New-Item -ItemType Directory -Force .llamagent\skills New-Item AGENTS.md # macOS mkdir -p .llamagent/skills touch AGENTS.md
# Projet calc - Les scripts se lancent avec `python <fichier>`. - Les tests sont dans tests/ et se lancent avec `python -m pytest -q`. - Ne modifie jamais les fichiers du dossier data/.
# Réglages propres à chaque projet (étape 9) : un dossier .llamagent/ dans # le dossier de travail, et un fichier AGENTS.md à sa racine. PROJECT_DIR_NAME = ".llamagent" PROJECT_INSTRUCTIONS = "AGENTS.md"
@dataclass
class ToolContext:
"""Ce dont les outils ont besoin pour travailler, fourni par le harness."""
workdir: Path # dossier de travail : les outils ne sortent pas d'ici
skills_dirs: list[Path] # dossiers des skills, du plus général au plus précis
confirm: Callable[[str], bool] # pose une question [o/N] à l'utilisateur
command_timeout: int = COMMAND_TIMEOUT
# Commandes lancées sans confirmation (étape 14), modifiable avec /allow.
allowed_commands: list[str] = field(default_factory=list)
def load_skill(ctx: ToolContext, name: str) -> str:
"""Renvoie le contenu d'une skill (fichier Markdown d'un dossier de skills)."""
skills = find_skills(ctx.skills_dirs)
# On vérifie le nom dans la liste : impossible de lire un autre fichier.
if name not in skills:
raise GuardError(f"skill inconnue '{name}'. Skills disponibles : {', '.join(skills) or 'aucune'}.")
return skills[name].read_text(encoding="utf-8")
def find_skills(skills_dirs: list[Path]) -> dict[str, Path]:
"""Renvoie {nom de la skill: fichier} pour les fichiers .md de chaque dossier.
Les dossiers sont lus dans l'ordre : une skill du projet (dernier dossier)
remplace une skill globale du même nom.
"""
skills = {}
for skills_dir in skills_dirs:
if skills_dir.is_dir():
skills.update({p.stem: p for p in sorted(skills_dir.glob("*.md"))})
return skills
def available_skills(skills_dirs: list[Path]) -> dict[str, str]:
"""Renvoie {nom de la skill: description}, pour le prompt système."""
return {name: read_skill_description(path) for name, path in find_skills(skills_dirs).items()}def project_skills_dirs(workdir: Path) -> list[Path]:
"""Skills globales (dépôt llamagent), puis skills du projet, qui l'emportent."""
return [SKILLS_DIR, workdir / PROJECT_DIR_NAME / "skills"]
def load_project_instructions(workdir: Path) -> str:
"""Contenu du fichier AGENTS.md du projet, ou "" s'il n'y en a pas."""
path = workdir / PROJECT_INSTRUCTIONS
if not path.is_file():
return ""
# Tronqué : le prompt système est renvoyé à chaque appel, il doit rester court.
return truncate(path.read_text(encoding="utf-8"), MAX_OUTPUT_CHARS)def build_system_prompt(template: str, workdir: Path, skills: dict[str, str], project_instructions: str = "") -> str:
"""Remplit le modèle de prompt (prompts/AGENT.md) avec l'environnement réel.
Si le projet a un fichier AGENTS.md (étape 9), son contenu est ajouté à la fin.
"""
shell = "cmd.exe" if os.name == "nt" else "/bin/sh"
skills_text = "\n".join(f"- {name} : {description}" for name, description in skills.items())
# str.replace plutôt que str.format : le Markdown peut contenir des { }.
prompt = (
template.replace("{os}", platform.system())
.replace("{shell}", shell)
.replace("{workdir}", str(workdir))
.replace("{skills}", skills_text or "(aucune)")
)
if project_instructions:
prompt += f"\n\n## Instructions du projet (AGENTS.md)\n{project_instructions}"
return promptCe qu'on doit observer
Au démarrage, une ligne de plus quand le projet a un AGENTS.md (sortie réelle) :
llamagent — modèle qwen3.5:4b sur http://127.0.0.1:8775 Dossier de travail : …/projet Instructions du projet : AGENTS.md Tape ta demande, ou /help pour les commandes.
/skills(étape 10) liste les skills globales et celles du projet.- « Lis ../autre-projet/main.py » : le modèle reçoit
Refusé : chemin refusé (../autre-projet/main.py) : '..' est interdit..
Pièges à surveiller
- « llamagent n'est pas reconnu » : le dossier des commandes uv n'est pas dans le PATH.
uv tool update-shell, puis rouvrir le terminal. - Sans
-e,prompts/etskills/ne font pas partie du paquet installé : erreur au démarrage. - Après une modification de
pyproject.toml(nouvelle dépendance, nouvelle commande), il faut réinstaller :uv tool install -e . --force. - Le confinement porte sur l'endroit, pas sur le contenu : un fichier
.envdu projet reste lisible parread_file. Etrun_commandpeut toujours écrire ailleurs : voir l'étape 14. - Un
AGENTS.mdlong occupe le contexte à chaque appel : il est tronqué à 4000 caractères. - Une skill ajoutée en cours de session apparaît tout de suite dans
/skills, mais le modèle ne la voit qu'après un/clearou un redémarrage : le prompt système est construit au début de la conversation.
Objectif
Piloter l'agent pendant la conversation : changer de modèle, de température ou de taille de contexte, charger une skill, effacer la conversation.
Concept
Deux sortes d'actions coexistent maintenant : les outils, demandés par le modèle (read_file…), et les commandes, tapées par l'utilisateur (/model…). Le modèle ne voit jamais les commandes : elles agissent sur le harness.
- Session : la configuration, le contexte des outils et les messages sont regroupés dans un objet.
Configest figée (frozen=True) : pour changer un réglage,dataclasses.replace()crée une nouvelle configuration, et la conversation est conservée. Session.chatlitself.configà chaque appel : un/temps'applique dès la demande suivante.- Registre de commandes : le même principe que le registre d'outils, un dictionnaire nom → (fonction, aide). Ajouter une commande ne touche ni à la boucle ni à
cli.py. /modelsinterrogeGET /api/tags: la liste des modèles installés sur le serveur Ollama./initprépare le projet courant : il créeAGENTS.md,.llamagent/config.toml(réglages en commentaire, donc sans effet tant qu'on ne les active pas),.llamagent/.gitignore(qui exclut les sauvegardes de l'étape 15 de git) et.llamagent/skills/LISEZMOI.txt, qui explique le format des skills. Il demande confirmation, ne remplace jamais un fichier existant et reconstruit le prompt système pour y inclureAGENTS.md. Le fichier d'explication est un.txt: une skill d'exemple en.mdserait listée dans le prompt système, et le modèle pourrait la charger pour rien.
"""Appel au LLM via l'API native d'Ollama.
C'est le seul module qui parle au réseau. Le reste du projet ne manipule
que des listes de messages (des dictionnaires Python).
"""
import requests
from llamagent.config import REQUEST_TIMEOUT, Config
def chat_full(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation à /api/chat et renvoie la réponse JSON complète.
En plus du message, la réponse contient des compteurs utiles, comme
prompt_eval_count (tokens lus) et eval_count (tokens générés).
"""
payload = {
"model": config.model,
"messages": messages,
"stream": False,
"think": False,
"options": config.options(),
}
if tools:
payload["tools"] = tools
response = requests.post(config.chat_url, json=payload, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.json()
def chat(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation (et la liste des outils) à Ollama.
Renvoie le message de l'assistant, par exemple :
{"role": "assistant", "content": "Bonjour !"}
ou, si le modèle veut utiliser un outil :
{"role": "assistant", "content": "",
"tool_calls": [{"function": {"name": "read_file",
"arguments": {"path": "main.py"}}}]}
"""
return chat_full(config, messages, tools)["message"]
def list_models(config: Config) -> list[str]:
"""Noms des modèles installés sur le serveur Ollama (GET /api/tags)."""
response = requests.get(f"{config.ollama_url}/api/tags", timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return sorted(model["name"] for model in response.json()["models"])@dataclass
class Session:
"""L'état d'une conversation : réglages, outils, messages."""
config: Config
template: str # contenu de prompts/AGENT.md
ctx: ToolContext
messages: list[dict] = field(default_factory=list)
context_tokens: int = 0 # taille du dernier échange, d'après Ollama
context_estimated: bool = False # vrai si context_tokens est une estimation (étape 16)
session_id: str | None = None # nom du fichier de sauvegarde (étape 15)
title: str = "" # titre de la sauvegarde : début de la première demande
def __post_init__(self) -> None:
self.reset()
def system_prompt(self) -> str:
skills = available_skills(self.ctx.skills_dirs)
instructions = load_project_instructions(self.config.workdir)
return build_system_prompt(self.template, self.config.workdir, skills, instructions)
def reset(self) -> None:
"""Repart d'une conversation vide. Le prompt système est relu :
une skill ou un AGENTS.md modifié est pris en compte."""
self.messages = [{"role": "system", "content": self.system_prompt()}]
self.context_tokens = 0
# Nouvelle conversation : elle sera sauvegardée dans un nouveau fichier.
self.session_id = None
self.title = ""
def remember_title(self) -> None:
"""Retient le début de la première demande comme titre (étape 15).
Le titre est fixé une fois pour toutes : après un compactage, la
première demande a disparu, mais le titre reste le même.
"""
if not self.title:
first = next((m["content"] for m in self.messages if m["role"] == "user"), "")
self.title = first.splitlines()[0][:TITLE_LENGTH] if first else ""
def update(self, **changes) -> None:
"""Change un réglage (modèle, température...) en gardant la conversation."""
self.config = replace(self.config, **changes)
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
"""La fonction `chat` donnée à la boucle. Elle lit self.config à chaque
appel : un /model ou un /temp s'applique dès l'appel suivant."""
data = llm.chat_full(self.config, messages, tools)
# Tokens lus + tokens générés : la place occupée dans le contexte.
self.context_tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
self.context_estimated = False
return data["message"]
def ask(self, text: str) -> str:
"""Traite une demande de l'utilisateur et renvoie la réponse finale."""
self.messages.append({"role": "user", "content": expand_mentions(text, self.config.workdir)})
return run_turn(self.chat, self.messages, self.ctx)
def create_session(config: Config, confirm: Callable[[str], bool]) -> Session:
"""Assemble une session : prompt système, outils, skills du projet."""
ctx = ToolContext(
workdir=config.workdir,
skills_dirs=project_skills_dirs(config.workdir),
confirm=confirm,
allowed_commands=list(config.allowed_commands),
)
template = (PROMPTS_DIR / "AGENT.md").read_text(encoding="utf-8")
return Session(config=config, template=template, ctx=ctx)"""Les commandes /... tapées par l'utilisateur (et non par le modèle).
Même principe que le registre d'outils : un dictionnaire
nom -> (fonction, aide). Ajouter une commande ne touche ni à la boucle
ni à cli.py. Chaque fonction reçoit la session et le texte tapé après la
commande, et renvoie le texte à afficher.
"""
from typing import Callable
import requests
from llamagent import llm
from llamagent.context import compact, context_report
from llamagent.config import PROJECT_DIR_NAME, PROJECT_INSTRUCTIONS
from llamagent.guards import SHELL_OPERATORS, GuardError
from llamagent.history import delete_session, find_session, list_sessions, resume_session, save_session
from llamagent.session import Session
from llamagent.tools import available_skills, load_skill
def cmd_help(session: Session, arg: str) -> str:
lines = [f"{name:<9} {description}" for name, (_, description) in COMMANDS.items()]
return "\n".join(lines + [f"{'/quit':<9} quitter", "", "@chemin dans une demande : joint le contenu du fichier."])
# Fichiers créés par /init dans le projet : chemin relatif -> contenu.
INIT_FILES = {
PROJECT_INSTRUCTIONS: """# Instructions du projet
- But du projet : ...
- Lancer le projet : ...
- Lancer les tests : python -m pytest -q
- Conventions de code : ...
- Fichiers ou dossiers à ne jamais modifier : ...
""",
f"{PROJECT_DIR_NAME}/config.toml": """# Réglages de llamagent pour ce projet (ils remplacent ~/.llamagent/config.toml).
# Retire le # devant une ligne pour l'activer.
# model = "qwen3.5:4b"
# temperature = 0.2
# num_ctx = 8192
# allowed_commands = ["python -m pytest", "git status", "git diff"]
""",
# Les conversations sauvegardées contiennent le contenu des fichiers lus :
# elles ne doivent pas finir dans le dépôt git du projet.
f"{PROJECT_DIR_NAME}/.gitignore": "sessions/\n",
f"{PROJECT_DIR_NAME}/skills/LISEZMOI.txt": """Skills de ce projet : un fichier .md par skill, par exemple deployer.md.
Format :
---
description: Une ligne qui dit quand utiliser cette skill.
---
# Titre
1. Première étape de la méthode.
2. ...
Le prompt système ne contient que le nom et la description de chaque skill.
Le modèle charge le contenu avec load_skill quand la demande correspond.
Une skill de ce dossier remplace une skill globale du même nom.
Ce fichier .txt n'est pas une skill : il est ignoré.
""",
}
def cmd_init(session: Session, arg: str) -> str:
workdir = session.config.workdir
# On ne remplace jamais un fichier existant : seuls les manquants sont créés.
missing = [rel for rel in INIT_FILES if not (workdir / rel).exists()]
if not missing:
return "Ce projet est déjà initialisé : rien à créer."
listing = "\n".join(f" {rel}" for rel in missing)
if not session.ctx.confirm(f"Fichiers à créer :\n{listing}\nLes créer dans {workdir} ?"):
return "Rien n'a été créé."
for rel in missing:
path = workdir / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(INIT_FILES[rel], encoding="utf-8")
# Le prompt système est reconstruit pour tenir compte d'AGENTS.md,
# sans effacer la conversation en cours.
session.messages[0] = {"role": "system", "content": session.system_prompt()}
return f"Créé :\n{listing}\nComplète AGENTS.md, puis ajoute tes skills dans {PROJECT_DIR_NAME}/skills/."
def cmd_config(session: Session, arg: str) -> str:
c = session.config
return (
f"modèle : {c.model}\n"
f"serveur : {c.ollama_url}\n"
f"température : {c.temperature}\n"
f"contexte : {c.num_ctx} tokens\n"
f"dossier : {c.workdir}\n"
f"sans confirmer : {', '.join(session.ctx.allowed_commands) or 'aucune commande'}"
)
def cmd_models(session: Session, arg: str) -> str:
models = llm.list_models(session.config)
return "\n".join(("* " if name == session.config.model else " ") + name for name in models) or "(aucun modèle)"
def cmd_model(session: Session, arg: str) -> str:
if not arg:
return f"Modèle actuel : {session.config.model}"
models = llm.list_models(session.config)
# « qwen3.5 » désigne « qwen3.5:latest » pour Ollama.
name = arg if arg in models else f"{arg}:latest"
if name not in models:
return f"Modèle inconnu : {arg}. Tape /models pour voir les modèles installés."
session.update(model=name)
return f"Modèle : {name} (la conversation continue avec ce modèle)"
def cmd_temp(session: Session, arg: str) -> str:
if not arg:
return f"Température actuelle : {session.config.temperature}"
try:
value = float(arg)
except ValueError:
return "Usage : /temp 0.2"
if not 0 <= value <= 2:
return "La température doit être comprise entre 0 et 2."
session.update(temperature=value)
return f"Température : {value}"
def cmd_ctx(session: Session, arg: str) -> str:
if not arg:
return f"Contexte : {session.config.num_ctx} tokens"
try:
value = int(arg)
except ValueError:
return "Usage : /ctx 8192"
if not 2048 <= value <= 131072:
return "Le contexte doit être compris entre 2048 et 131072 tokens."
session.update(num_ctx=value)
return f"Contexte : {value} tokens (plus de contexte = plus de VRAM utilisée)"
def cmd_skills(session: Session, arg: str) -> str:
skills = available_skills(session.ctx.skills_dirs)
return "\n".join(f"{name} : {description}" for name, description in skills.items()) or "(aucune skill)"
def cmd_skill(session: Session, arg: str) -> str:
if not arg:
return "Usage : /skill nom (voir /skills)"
try:
content = load_skill(session.ctx, arg)
except GuardError as error:
return str(error)
# Ajoutée à la conversation : le modèle la verra dès la prochaine demande.
session.messages.append({"role": "user", "content": f"Pour la suite, suis les instructions de la skill « {arg} » :\n\n{content}"})
return f"Skill {arg} chargée dans la conversation."
def cmd_clear(session: Session, arg: str) -> str:
session.reset()
return "Conversation effacée. Prompt système, skills et AGENTS.md relus."
def cmd_sessions(session: Session, arg: str) -> str:
sessions = list_sessions(session.config.workdir)
if not sessions:
return "Aucune conversation sauvegardée dans ce projet."
lines = []
for number, data in enumerate(sessions, start=1):
current = "*" if data["id"] == session.session_id else " "
date = data.get("updated", "?").replace("T", " ")[:16]
lines.append(f"{current}{number:>3}. {date} {len(data.get('messages', [])):>3} messages {data.get('title', '')}")
return "\n".join(lines + ["", "/resume <numéro> pour reprendre, /delete <numéro> pour supprimer."])
def cmd_resume(session: Session, arg: str) -> str:
# Sans argument : la conversation la plus récente.
data = find_session(session.config.workdir, arg or "1")
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
resume_session(session, data)
return f"Conversation reprise : {data.get('title', '')} ({len(data['messages'])} messages)."
def cmd_delete(session: Session, arg: str) -> str:
if not arg:
return "Usage : /delete <numéro> (voir /sessions)"
data = find_session(session.config.workdir, arg)
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
if not session.ctx.confirm(f"Supprimer la conversation « {data.get('title', '')} » ?"):
return "Rien n'a été supprimé."
delete_session(data)
if data["id"] == session.session_id:
session.session_id = None # la conversation en cours sera sauvegardée dans un nouveau fichier
return f"Conversation supprimée : {data.get('title', '')}"
def cmd_context(session: Session, arg: str) -> str:
return context_report(session)
def cmd_compact(session: Session, arg: str) -> str:
if len(session.messages) <= 1:
return "Rien à compacter : la conversation est vide."
summary = compact(session)
save_session(session) # le fichier sauvegardé contient désormais le résumé
return f"Conversation compactée. Le modèle repart de ce résumé :\n\n{summary}"
def cmd_allow(session: Session, arg: str) -> str:
allowed = session.ctx.allowed_commands
if not arg:
return "Commandes lancées sans confirmation : " + (", ".join(allowed) or "aucune")
if any(operator in arg for operator in SHELL_OPERATORS):
return "Refusé : une commande autorisée ne doit contenir aucun opérateur shell (& | ; > < ...)."
allowed.append(arg)
return f"« {arg} » sera lancée sans confirmation jusqu'à la fin de la session."
# nom de la commande -> (fonction, aide affichée par /help)
COMMANDS: dict[str, tuple[Callable[[Session, str], str], str]] = {
"/help": (cmd_help, "liste des commandes"),
"/init": (cmd_init, "préparer le projet : AGENTS.md et dossier .llamagent/"),
"/config": (cmd_config, "réglages actuels"),
"/models": (cmd_models, "modèles installés sur le serveur Ollama"),
"/model": (cmd_model, "changer de modèle : /model qwen3.5:4b"),
"/temp": (cmd_temp, "changer la température : /temp 0.5"),
"/ctx": (cmd_ctx, "changer la taille du contexte : /ctx 16384"),
"/skills": (cmd_skills, "skills disponibles"),
"/skill": (cmd_skill, "charger une skill : /skill corriger-un-bug"),
"/clear": (cmd_clear, "effacer la conversation"),
"/context": (cmd_context, "ce qui occupe le contexte"),
"/compact": (cmd_compact, "résumer la conversation pour libérer du contexte"),
"/sessions": (cmd_sessions, "conversations sauvegardées dans ce projet"),
"/resume": (cmd_resume, "reprendre une conversation : /resume 2 (sans numéro : la dernière)"),
"/delete": (cmd_delete, "supprimer une conversation : /delete 2"),
"/allow": (cmd_allow, "lancer une commande sans confirmation : /allow python -m pytest"),
}
def execute_command(session: Session, line: str) -> str:
"""Exécute une ligne comme « /temp 0.5 » et renvoie le texte à afficher."""
name, _, arg = line.strip().partition(" ")
if name not in COMMANDS:
return f"Commande inconnue : {name}. Tape /help."
function, _ = COMMANDS[name]
try:
return function(session, arg.strip())
except requests.RequestException as error:
return f"Erreur de communication avec Ollama : {error}"def main() -> None:
if os.name == "nt":
os.system("") # active les couleurs ANSI dans les anciennes consoles Windows
args = parse_args()
config = load_config({"model": args.model, "temperature": args.temperature, "num_ctx": args.num_ctx, "url": args.url})
session = create_session(config, confirm=ask_confirmation)
print(f"llamagent — modèle {config.model} sur {config.ollama_url}")
print(f"Dossier de travail : {config.workdir}")
if load_project_instructions(config.workdir):
print("Instructions du projet : AGENTS.md")
if args.resume:
data = find_session(config.workdir, "1")
if data is None:
print("Aucune conversation à reprendre dans ce projet : nouvelle conversation.")
else:
resume_session(session, data)
print(f"Conversation reprise : {data.get('title', '')} ({len(data['messages'])} messages)")
try:
# Mode « une seule demande » : la demande est passée en argument.
if args.demande:
handle_request(session, " ".join(args.demande))
return
print("Tape ta demande, ou /help pour les commandes.")
while True:
text = input("\nvous > ").strip()
if text in ("/quit", "/exit"):
break
if text.startswith("/"):
print(execute_command(session, text))
elif text:
handle_request(session, text)
except (KeyboardInterrupt, EOFError):
print("\nAu revoir.")Essayer
/init /models /model qwen3.5:9b /temp 0.5 /ctx 16384 /skills /skill corriger-un-bug /config /clear
Ce qu'on doit observer
Sorties réelles du harness, d'abord /init dans un projet vide :
vous > /init Fichiers à créer : AGENTS.md .llamagent/config.toml .llamagent/.gitignore .llamagent/skills/LISEZMOI.txt Les créer dans …/rfact ? [o/N] o Créé : AGENTS.md .llamagent/config.toml .llamagent/.gitignore .llamagent/skills/LISEZMOI.txt Complète AGENTS.md, puis ajoute tes skills dans .llamagent/skills/. vous > /init Ce projet est déjà initialisé : rien à créer.
vous > /help /help liste des commandes /init préparer le projet : AGENTS.md et dossier .llamagent/ /config réglages actuels /models modèles installés sur le serveur Ollama /model changer de modèle : /model qwen3.5:4b /temp changer la température : /temp 0.5 /ctx changer la taille du contexte : /ctx 16384 /skills skills disponibles /skill charger une skill : /skill corriger-un-bug /clear effacer la conversation /context ce qui occupe le contexte /compact résumer la conversation pour libérer du contexte /sessions conversations sauvegardées dans ce projet /resume reprendre une conversation : /resume 2 (sans numéro : la dernière) /delete supprimer une conversation : /delete 2 /allow lancer une commande sans confirmation : /allow python -m pytest /quit quitter @chemin dans une demande : joint le contenu du fichier.
vous > /config modèle : qwen3.5:4b serveur : http://127.0.0.1:8775 température : 0.1 contexte : 8192 tokens dossier : …/projet sans confirmer : python calc.py
vous > /models llama3.2:3b * qwen3.5:4b qwen3.5:9b vous > /model qwen3.5:9b Modèle : qwen3.5:9b (la conversation continue avec ce modèle) vous > /temp 0.3 Température : 0.3
Tests
"""Tests des commandes /... (commands.py)."""
import pytest
from llamagent import llm
from llamagent.commands import COMMANDS, execute_command
from llamagent.config import Config
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path, monkeypatch):
monkeypatch.setattr(llm, "list_models", lambda config: ["qwen3.5:4b", "qwen3.5:latest", "llama3.2:3b"])
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def test_help_liste_toutes_les_commandes(session):
aide = execute_command(session, "/help")
assert all(name in aide for name in COMMANDS)
def test_commande_inconnue(session):
assert "Commande inconnue : /foo" in execute_command(session, "/foo")
def test_temperature(session):
assert execute_command(session, "/temp 0.7") == "Température : 0.7"
assert session.config.temperature == 0.7
assert "entre 0 et 2" in execute_command(session, "/temp 5")
assert "Usage" in execute_command(session, "/temp chaud")
assert session.config.temperature == 0.7
def test_contexte(session):
execute_command(session, "/ctx 16384")
assert session.config.num_ctx == 16384
assert "entre 2048" in execute_command(session, "/ctx 100")
def test_modeles_et_changement_de_modele(session):
assert "* qwen3.5:4b" in execute_command(session, "/models")
execute_command(session, "/model llama3.2:3b")
assert session.config.model == "llama3.2:3b"
execute_command(session, "/model qwen3.5") # -> qwen3.5:latest
assert session.config.model == "qwen3.5:latest"
assert "Modèle inconnu" in execute_command(session, "/model gpt-5")
assert session.config.model == "qwen3.5:latest"
def test_skill_chargee_dans_la_conversation(session):
assert "corriger-un-bug" in execute_command(session, "/skills")
execute_command(session, "/skill corriger-un-bug")
assert "Corriger un bug" in session.messages[-1]["content"]
assert "skill inconnue" in execute_command(session, "/skill inexistante")
def test_clear(session):
session.messages.append({"role": "user", "content": "x"})
execute_command(session, "/clear")
assert [m["role"] for m in session.messages] == ["system"]
def test_allow(session):
execute_command(session, "/allow python -m pytest")
assert session.ctx.allowed_commands == ["python -m pytest"]
assert execute_command(session, "/allow git status && del x").startswith("Refusé")
assert session.ctx.allowed_commands == ["python -m pytest"]
def test_init_cree_les_fichiers_du_projet(session, tmp_path):
from llamagent.config import read_toml
from llamagent.tools import available_skills
resultat = execute_command(session, "/init")
assert resultat.startswith("Créé :")
assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8").startswith("# Instructions du projet")
assert read_toml(tmp_path / ".llamagent" / "config.toml") == {} # tout est en commentaire
assert (tmp_path / ".llamagent" / "skills" / "LISEZMOI.txt").exists()
# Le LISEZMOI n'est pas une skill, et AGENTS.md est déjà dans le prompt système.
assert "LISEZMOI" not in available_skills(session.ctx.skills_dirs)
assert "## Instructions du projet (AGENTS.md)" in session.messages[0]["content"]
assert execute_command(session, "/init") == "Ce projet est déjà initialisé : rien à créer."
def test_init_ne_remplace_pas_un_fichier_existant(session, tmp_path):
(tmp_path / "AGENTS.md").write_text("Mes consignes.", encoding="utf-8")
resultat = execute_command(session, "/init")
assert " AGENTS.md" not in resultat # pas dans la liste des fichiers créés
assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "Mes consignes."
def test_init_refuse_par_l_utilisateur(session, tmp_path):
session.ctx.confirm = lambda question: False
assert execute_command(session, "/init") == "Rien n'a été créé."
assert not (tmp_path / "AGENTS.md").exists()
def test_sessions_reprise_et_suppression(session, tmp_path):
from llamagent.history import save_session
assert execute_command(session, "/sessions") == "Aucune conversation sauvegardée dans ce projet."
session.messages.append({"role": "user", "content": "Corrige le bug"})
save_session(session)
assert "Corrige le bug" in execute_command(session, "/sessions")
execute_command(session, "/clear")
assert execute_command(session, "/resume").startswith("Conversation reprise : Corrige le bug")
assert session.messages[-1]["content"] == "Corrige le bug"
assert "introuvable" in execute_command(session, "/resume 9")
session.ctx.confirm = lambda question: False
assert execute_command(session, "/delete 1") == "Rien n'a été supprimé."
session.ctx.confirm = lambda question: True
assert execute_command(session, "/delete 1").startswith("Conversation supprimée")
assert session.session_id is None
assert execute_command(session, "/sessions") == "Aucune conversation sauvegardée dans ce projet."
def test_context_et_compact(session, monkeypatch):
assert "total estimé" in execute_command(session, "/context")
assert execute_command(session, "/compact") == "Rien à compacter : la conversation est vide."
monkeypatch.setattr(llm, "chat", lambda config, messages, tools=None: {"role": "assistant", "content": "Résumé."})
session.messages.append({"role": "user", "content": "Bonjour"})
assert execute_command(session, "/compact").endswith("Résumé.")
# Le fichier sauvegardé contient la conversation compactée.
from llamagent.history import find_session
saved = find_session(session.config.workdir, "1")
assert "Résumé." in saved["messages"][0]["content"]
assert saved["title"] == "Bonjour" # le titre ne change pas après un compactage
def test_init_cree_un_gitignore_pour_les_sessions(session, tmp_path):
execute_command(session, "/init")
assert (tmp_path / ".llamagent" / ".gitignore").read_text(encoding="utf-8") == "sessions/\n"Pièges à surveiller
- Un modèle qui ne gère pas les outils (certains modèles anciens) : Ollama répond une erreur HTTP 400 (« does not support tools »). Le message s'affiche, la session continue : revenir en arrière avec
/model. - Changer
num_ctxoblige Ollama à recharger le modèle : la demande suivante est lente, et plus de contexte demande plus de VRAM (6 Go ici). Changer la température, non. - Après un
/model, le nouveau modèle relit tout l'historique, y compris les appels d'outils de l'ancien. Un petit modèle s'en sort souvent mieux après un/clear. /model qwen3.5désigneqwen3.5:latestpour Ollama, pasqwen3.5:4b.
Objectif
Joindre un fichier à une demande en écrivant @chemin, comme dans opencode ou Claude Code.
Concept
Le harness repère les @chemin dans la demande, lit les fichiers (toujours via safe_path) et ajoute leur contenu à la fin du message. Le modèle a le contenu sous les yeux sans appeler read_file : un aller-retour de moins, et la certitude qu'il travaille sur le bon fichier.
L'expression régulière (?<!\S)@([\w./\\-]+) ne retient un @ qu'en début de texte ou après un espace : nils@exemple.fr n'est pas pris pour un fichier.
# « @chemin » en début de texte ou après un espace : une adresse e-mail
# comme nils@exemple.fr n'est donc pas prise pour un fichier.
MENTION = re.compile(r"(?<!\S)@([\w./\\-]+)")
def find_mentions(text: str) -> list[str]:
"""Chemins tagués avec @ dans la demande, sans doublon, dans l'ordre."""
# rstrip : dans « regarde @main.py. » le point final n'est pas au fichier.
paths = [match.rstrip(".,;:") for match in MENTION.findall(text)]
return list(dict.fromkeys(path for path in paths if path))
def expand_mentions(text: str, workdir: Path) -> str:
"""Ajoute à la demande le contenu des fichiers tagués avec @chemin.
Le modèle reçoit directement le contenu : pas besoin d'un appel à
read_file, donc un tour de boucle en moins.
"""
blocks = []
for path in find_mentions(text):
try:
content = truncate(safe_path(workdir, path).read_text(encoding="utf-8"), MAX_OUTPUT_CHARS)
except (GuardError, OSError, UnicodeDecodeError) as error:
content = f"(fichier illisible : {error})"
blocks.append(f"--- {path} ---\n{content}")
if not blocks:
return text
return text + "\n\nFichiers mentionnés :\n\n" + "\n\n".join(blocks)class Session:
...
def ask(self, text: str) -> str:
"""Traite une demande de l'utilisateur et renvoie la réponse finale."""
self.messages.append({"role": "user", "content": expand_mentions(text, self.config.workdir)})
return run_turn(self.chat, self.messages, self.ctx)def handle_request(session: Session, text: str) -> None:
"""Envoie une demande à l'agent et affiche sa réponse."""
for path in find_mentions(text):
print(color(f" + fichier joint : {path}", GREY))
try:
answer = session.ask(text)
except requests.RequestException as error:
print(f"Erreur de communication avec Ollama : {error}")
return
print(f"\nagent > {answer}")
print(context_status(session))
# Étape 16 : entre deux demandes, on compacte si le contexte est presque plein.
if needs_compaction(session):
print(color("Contexte presque plein : compactage automatique de la conversation...", YELLOW))
try:
compact(session)
print(context_status(session))
except requests.RequestException as error:
print(f"Compactage impossible : {error}")
# Étape 15 : la conversation est sauvegardée après chaque demande.
save_session(session)Essayer
llamagent "Corrige le bug de @calc.py"
Ce qu'on doit observer
Sortie réelle du harness, puis le message réellement envoyé au modèle :
vous > Corrige le bug de @calc.py + fichier joint : calc.py
Corrige le bug de @calc.py
Fichiers mentionnés :
--- calc.py ---
def add(a, b):
return a - b
print(add(2, 3))Tests
"""Tests de la session (session.py) : projet, fichiers tagués, réglages."""
from pathlib import Path
import pytest
from llamagent import llm
from llamagent.config import Config
from llamagent.session import create_session, expand_mentions, find_mentions
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def test_mentions():
texte = "Regarde @src/app.py et @README.md. Écris à nils@exemple.fr, puis relis @src/app.py"
assert find_mentions(texte) == ["src/app.py", "README.md"]
def test_contenu_des_fichiers_mentionnes(tmp_path):
(tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8")
texte = expand_mentions("Explique @app.py", tmp_path)
assert texte.startswith("Explique @app.py\n\nFichiers mentionnés :")
assert "--- app.py ---\nprint('ok')" in texte
def test_mention_hors_du_dossier_ou_absente(tmp_path):
texte = expand_mentions("Lis @../secret.txt et @absent.py", tmp_path)
assert "--- ../secret.txt ---\n(fichier illisible : chemin refusé" in texte
assert "--- absent.py ---\n(fichier illisible :" in texte
def test_sans_mention_le_texte_ne_change_pas(tmp_path):
assert expand_mentions("Bonjour", tmp_path) == "Bonjour"
def test_agents_md_dans_le_prompt_systeme(tmp_path, session):
assert "AGENTS.md" not in session.messages[0]["content"]
(tmp_path / "AGENTS.md").write_text("Les tests sont dans tests/.", encoding="utf-8")
session.reset() # /clear relit le fichier
assert session.messages[0]["content"].endswith("Les tests sont dans tests/.")
def test_changer_de_modele_garde_la_conversation(monkeypatch, session):
vus = []
def faux_chat_full(config, messages, tools):
vus.append((config.model, config.temperature, len(messages)))
return {"message": {"role": "assistant", "content": "ok"}, "prompt_eval_count": 1200, "eval_count": 30}
monkeypatch.setattr(llm, "chat_full", faux_chat_full)
session.ask("Bonjour")
session.update(model="qwen3.5:9b", temperature=0.6)
session.ask("Et maintenant ?")
assert vus == [("qwen3.5:4b", 0.2, 2), ("qwen3.5:9b", 0.6, 4)]
assert session.context_tokens == 1230
def test_skills_du_projet(tmp_path, session):
assert session.ctx.skills_dirs[-1] == tmp_path / ".llamagent" / "skills"
assert session.ctx.skills_dirs[0].name == "skills"Pièges à surveiller
- Les chemins avec des espaces ne sont pas reconnus : l'expression s'arrête au premier espace.
- Un gros fichier occupe beaucoup de contexte : il est tronqué à 4000 caractères (début et fin).
- Taguer un dossier donne « fichier illisible » : pour un dossier, demander plutôt
list_files. - Le contenu joint reste dans l'historique : le joindre une deuxième fois le duplique.
Objectif
Régler l'agent sans toucher au code : options en ligne de commande, et fichiers config.toml global et par projet.
Concept
Les réglages viennent de cinq sources ; chacune remplace la précédente :
- les valeurs par défaut de
config.py; - le fichier global
~/.llamagent/config.toml(sous Windows :C:\Users\nilsm\.llamagent\config.toml) ; - le fichier du projet
.llamagent/config.toml; - les variables d'environnement
OLLAMA_URLetOLLAMA_MODEL; - les options de la ligne de commande (
--model,--temperature…).
tomllib, qui lit le TOML, fait partie de Python depuis la 3.11 : toujours une seule dépendance. Une clé inconnue (faute de frappe) ou une valeur invalide arrête l'agent avec un message clair au lieu d'être ignorée. argparse, lui aussi inclus dans Python, lit les options ; une option non donnée vaut None et ne remplace rien.
# ~/.llamagent/config.toml (Windows : C:\Users\nilsm\.llamagent\config.toml) url = "http://192.168.1.76:11434" model = "qwen3.5:4b" temperature = 0.2 num_ctx = 8192
# mon-projet/.llamagent/config.toml temperature = 0.1 allowed_commands = ["python -m pytest", "git status", "git diff"]
"""Configuration de llamagent.
Les réglages viennent de plusieurs sources. Chacune remplace la précédente :
1. les valeurs par défaut ci-dessous (adaptées à mon NAS) ;
2. le fichier global ~/.llamagent/config.toml ;
3. le fichier du projet .llamagent/config.toml (dans le dossier de travail) ;
4. les variables d'environnement OLLAMA_URL et OLLAMA_MODEL ;
5. les options de la ligne de commande (--model, --temperature...).
Les limites des garde-fous, elles, sont fixées ici.
"""
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
# Valeurs par défaut : Ollama sur le NAS, modèle 4B qui tient dans 6 Go de VRAM.
DEFAULT_OLLAMA_URL = "http://192.168.1.76:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
# Température basse : on veut un agent prévisible, pas créatif.
TEMPERATURE = 0.2
# Taille de la fenêtre de contexte (en tokens) demandée à Ollama.
# 8192 est un bon compromis pour un modèle 4B sur une carte de 6 Go.
NUM_CTX = 8192
# Délai maximal d'une requête à Ollama, en secondes. Le premier appel est
# lent car Ollama doit charger le modèle en VRAM.
REQUEST_TIMEOUT = 120
# Garde-fous (étape 6).
MAX_TOURS = 15 # nombre maximal d'appels au LLM pour une même demande
COMMAND_TIMEOUT = 30 # durée maximale d'une commande lancée par run_command (s)
MAX_OUTPUT_CHARS = 4000 # taille maximale d'un résultat d'outil (~1000 tokens)
# Compactage automatique quand le contexte est rempli à 80 % (étape 16).
COMPACT_THRESHOLD = 0.8
# Dossiers du projet llamagent : prompts/ et skills/ sont à la racine du
# dépôt, deux niveaux au-dessus de ce fichier (src/llamagent/config.py).
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PROMPTS_DIR = PROJECT_ROOT / "prompts"
SKILLS_DIR = PROJECT_ROOT / "skills"
# Réglages propres à chaque projet (étape 9) : un dossier .llamagent/ dans
# le dossier de travail, et un fichier AGENTS.md à sa racine.
PROJECT_DIR_NAME = ".llamagent"
PROJECT_INSTRUCTIONS = "AGENTS.md"
# Fichier de réglages global de l'utilisateur (étape 12).
USER_CONFIG = Path.home() / PROJECT_DIR_NAME / "config.toml"
# Les seules clés acceptées dans un fichier config.toml.
SETTINGS_KEYS = {"url", "model", "temperature", "num_ctx", "allowed_commands"}
@dataclass(frozen=True)
class Config:
"""Réglages de la session. Pour en changer un : dataclasses.replace()."""
ollama_url: str
model: str
workdir: Path # le seul dossier où l'agent a le droit de lire et d'écrire
temperature: float = TEMPERATURE
num_ctx: int = NUM_CTX
# Commandes que run_command lance sans demander confirmation (étape 14).
allowed_commands: tuple[str, ...] = ()
@property
def chat_url(self) -> str:
"""URL de l'API native d'Ollama (et non la couche /v1 compatible OpenAI)."""
return f"{self.ollama_url}/api/chat"
def options(self) -> dict:
"""Bloc `options` envoyé à Ollama à chaque appel."""
return {"temperature": self.temperature, "num_ctx": self.num_ctx}
def read_toml(path: Path) -> dict:
"""Lit un fichier de réglages TOML. Renvoie {} s'il n'existe pas."""
if not path.is_file():
return {}
try:
settings = tomllib.loads(path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as error:
raise SystemExit(f"Fichier de réglages invalide : {path}\n{error}")
# Une faute de frappe (« temperatur ») serait sinon ignorée sans prévenir.
unknown = set(settings) - SETTINGS_KEYS
if unknown:
raise SystemExit(f"Réglage inconnu dans {path} : {', '.join(sorted(unknown))}")
return settings
def load_config(cli_options: dict | None = None) -> Config:
"""Construit la configuration à partir de toutes les sources, dans l'ordre."""
# Par défaut, l'agent travaille dans le dossier où on le lance.
workdir = Path(os.environ.get("LLAMAGENT_WORKDIR", Path.cwd())).resolve()
# Un dossier de travail absent ferait échouer tous les outils plus tard,
# avec des erreurs obscures (WinError 267 sous Windows) : on s'arrête net.
if not workdir.is_dir():
raise SystemExit(f"Dossier de travail introuvable : {workdir} (vérifie LLAMAGENT_WORKDIR).")
settings = {"url": DEFAULT_OLLAMA_URL, "model": DEFAULT_OLLAMA_MODEL, "temperature": TEMPERATURE, "num_ctx": NUM_CTX}
settings.update(read_toml(USER_CONFIG))
settings.update(read_toml(workdir / PROJECT_DIR_NAME / "config.toml"))
if "OLLAMA_URL" in os.environ:
settings["url"] = os.environ["OLLAMA_URL"]
if "OLLAMA_MODEL" in os.environ:
settings["model"] = os.environ["OLLAMA_MODEL"]
# Options de la ligne de commande : seulement celles qui ont été données.
settings.update({key: value for key, value in (cli_options or {}).items() if value is not None})
try:
return Config(
# On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
ollama_url=str(settings["url"]).rstrip("/"),
model=str(settings["model"]),
workdir=workdir,
temperature=float(settings["temperature"]),
num_ctx=int(settings["num_ctx"]),
allowed_commands=tuple(str(command) for command in settings.get("allowed_commands", ())),
)
except (TypeError, ValueError) as error: # ex. temperature = "chaud"
raise SystemExit(f"Réglage invalide : {error}")def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="llamagent", description="Un agent de code minimal autour d'Ollama.")
parser.add_argument("demande", nargs="*", help="demande à traiter avant de quitter (sinon : mode conversation)")
parser.add_argument("-m", "--model", help="modèle Ollama, ex. qwen3.5:4b")
parser.add_argument("-t", "--temperature", type=float, help="température, ex. 0.2")
parser.add_argument("--num-ctx", type=int, help="taille du contexte en tokens, ex. 8192")
parser.add_argument("--url", help="adresse du serveur Ollama")
parser.add_argument("-c", "--continue", dest="resume", action="store_true", help="reprendre la dernière conversation du projet")
return parser.parse_args(argv)Essayer
llamagent --help llamagent -m qwen3.5:9b -t 0.5 llamagent --num-ctx 16384 "Résume @README.md"
Ce qu'on doit observer
Sortie réelle de llamagent --help :
usage: llamagent [-h] [-m MODEL] [-t TEMPERATURE] [--num-ctx NUM_CTX] [--url URL] [demande ...]
Un agent de code minimal autour d'Ollama.
positional arguments:
demande demande à traiter avant de quitter (sinon : mode conversation)
options:
-h, --help show this help message and exit
-m MODEL, --model MODEL
modèle Ollama, ex. qwen3.5:4b
-t TEMPERATURE, --temperature TEMPERATURE
température, ex. 0.2
--num-ctx NUM_CTX taille du contexte en tokens, ex. 8192
--url URL adresse du serveur Ollama/config affiche les réglages réellement appliqués : c'est le premier réflexe quand un réglage ne semble pas pris en compte.
Tests
"""Tests de la configuration (config.py)."""
import pytest
from llamagent import config as config_module
from llamagent.config import DEFAULT_OLLAMA_MODEL, load_config
@pytest.fixture(autouse=True)
def environnement_propre(monkeypatch, tmp_path):
"""Chaque test part sans variables d'environnement ni fichier global."""
for name in ("OLLAMA_URL", "OLLAMA_MODEL", "LLAMAGENT_WORKDIR"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config_module, "USER_CONFIG", tmp_path / "global" / "config.toml")
monkeypatch.chdir(tmp_path)
def write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def test_valeurs_par_defaut(monkeypatch, tmp_path):
monkeypatch.setenv("OLLAMA_URL", "http://localhost:11434/")
config = load_config()
assert config.model == DEFAULT_OLLAMA_MODEL
assert config.chat_url == "http://localhost:11434/api/chat" # "/" final retiré
assert config.workdir == tmp_path.resolve()
def test_dossier_de_travail_inexistant(monkeypatch, tmp_path):
monkeypatch.setenv("LLAMAGENT_WORKDIR", str(tmp_path / "absent"))
with pytest.raises(SystemExit, match="Dossier de travail introuvable"):
load_config()
def test_ordre_des_sources(monkeypatch, tmp_path):
write(config_module.USER_CONFIG, 'model = "global"\ntemperature = 0.5\nnum_ctx = 4096\n')
write(tmp_path / ".llamagent" / "config.toml", 'model = "projet"\ntemperature = 0.7\n')
monkeypatch.setenv("OLLAMA_MODEL", "variable")
config = load_config({"temperature": 0.9, "model": None}) # option non donnée : None
assert config.num_ctx == 4096 # fichier global
assert config.model == "variable" # la variable d'environnement bat les fichiers
assert config.temperature == 0.9 # l'option de ligne de commande bat tout
def test_commandes_autorisees_depuis_le_fichier(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", 'allowed_commands = ["python -m pytest", "git status"]\n')
assert load_config().allowed_commands == ("python -m pytest", "git status")
def test_reglage_inconnu(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", "temperatur = 0.5\n")
with pytest.raises(SystemExit, match="Réglage inconnu.*temperatur"):
load_config()
def test_fichier_toml_invalide(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", "model = \n")
with pytest.raises(SystemExit, match="Fichier de réglages invalide"):
load_config()
def test_valeur_invalide(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", 'temperature = "chaud"\n')
with pytest.raises(SystemExit, match="Réglage invalide"):
load_config()Pièges à surveiller
- En TOML, une chaîne entre guillemets doubles interprète les
\: écrire un chemin Windows entre apostrophes ('C:\Users\nilsm') ou avec des/. - Une variable
OLLAMA_MODELrestée définie dans le terminal l'emporte sur les fichiers : vérifier avec/config, l'effacer avecRemove-Item Env:OLLAMA_MODEL. - Les titres de l'aide (
usage,options) sont en anglais : ils viennent d'argparse, pas de llamagent.
Objectif
Voir la place occupée dans le contexte, et en consommer moins quand l'agent modifie un fichier.
Concept
Avec 8192 tokens, le contexte est la vraie limite d'un modèle 4B : prompt système, AGENTS.md, schémas des outils, fichiers joints et résultats d'outils s'y accumulent. À chaque appel, Ollama renvoie prompt_eval_count (tokens lus) et eval_count (tokens générés) : leur somme, affichée après chaque réponse, donne la place occupée. Au-delà de num_ctx, Ollama coupe le début de la conversation sans prévenir, y compris le prompt système.
write_file obligeait le modèle à renvoyer le fichier entier pour changer une ligne. edit_file(path, old_text, new_text) ne transporte que le passage modifié. Pour éviter une modification au mauvais endroit, old_text doit apparaître exactement une fois ; un old_text vide crée un nouveau fichier. edit_file remplace write_file : on reste à 5 outils.
Le prompt système et les skills sont mis à jour en conséquence, avec une règle ajoutée après ce qu'on a observé à l'étape 5 : « Pour lister les fichiers, utilise list_files, pas run_command ».
def chat_full(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation à /api/chat et renvoie la réponse JSON complète.
En plus du message, la réponse contient des compteurs utiles, comme
prompt_eval_count (tokens lus) et eval_count (tokens générés).
"""
payload = {
"model": config.model,
"messages": messages,
"stream": False,
"think": False,
"options": config.options(),
}
if tools:
payload["tools"] = tools
response = requests.post(config.chat_url, json=payload, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.json()class Session:
...
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
"""La fonction `chat` donnée à la boucle. Elle lit self.config à chaque
appel : un /model ou un /temp s'applique dès l'appel suivant."""
data = llm.chat_full(self.config, messages, tools)
# Tokens lus + tokens générés : la place occupée dans le contexte.
self.context_tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
self.context_estimated = False
return data["message"]def context_status(session: Session) -> str:
"""Une ligne qui montre la place occupée dans le contexte du modèle."""
used, size = session.context_tokens, session.config.num_ctx
text = f"[contexte : {'≈ ' if session.context_estimated else ''}{used} / {size} tokens]"
if used >= size:
return color(text + " plein : Ollama a coupé le début de la conversation", RED)
if used >= 0.75 * size:
return color(text + " presque plein", YELLOW)
return color(text, GREY)def edit_file(ctx: ToolContext, path: str, old_text: str, new_text: str) -> str:
"""Remplace un passage exact d'un fichier, ou crée un fichier si old_text est vide.
Le modèle n'envoie que le passage modifié, pas tout le fichier : c'est
beaucoup moins de contexte consommé, et le reste du fichier est intact.
"""
target = safe_path(ctx.workdir, path)
if old_text == "":
if target.exists():
return f"Erreur : {path} existe déjà. Pour le modifier, donne dans old_text le passage exact à remplacer."
old, content = "", new_text
else:
if not target.is_file():
return f"Erreur : fichier introuvable : {path}. Pour créer un fichier, laisse old_text vide."
old = target.read_text(encoding="utf-8")
count = old.count(old_text)
if count == 0:
return f"Erreur : old_text introuvable dans {path}. Relis le fichier avec read_file et copie le passage exactement."
if count > 1:
return f"Erreur : old_text apparaît {count} fois dans {path}. Ajoute des lignes autour pour qu'il soit unique."
content = old.replace(old_text, new_text, 1)
# On montre à l'utilisateur exactement ce qui va changer.
diff = "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile=f"{path} (avant)",
tofile=f"{path} (après)",
)
)
if not ctx.confirm(f"{diff or '(aucun changement)'}\nÉcrire {path} ?"):
return "Refusé par l'utilisateur : le fichier n'a pas été modifié."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Fichier {path} {'créé' if old_text == '' else 'modifié'}." "edit_file": (
edit_file,
schema(
"edit_file",
"Modifie un fichier : remplace old_text (copié exactement depuis read_file, présent une seule fois) par new_text. "
"Pour créer un nouveau fichier, laisse old_text vide.",
{
"path": PATH_PARAM,
"old_text": {"type": "string", "description": "Passage exact à remplacer, ou '' pour créer le fichier."},
"new_text": {"type": "string", "description": "Nouveau texte."},
},
["path", "old_text", "new_text"],
),
),Tu es llamagent, un assistant de programmation qui travaille dans un dossier de projet.
## Règles
- Utilise les outils pour lire et modifier les fichiers. N'invente jamais le contenu d'un fichier : lis-le.
- Avant de modifier un fichier, lis-le avec read_file.
- Pour modifier un fichier, utilise edit_file avec un passage copié exactement depuis read_file. Pour créer un fichier, edit_file avec old_text vide.
- Pour lister les fichiers, utilise list_files, pas run_command.
- Les chemins sont relatifs au dossier de travail.
- Après une correction, vérifie-la avec run_command quand c'est possible.
- N'utilise que les outils fournis.
- Quand la tâche est terminée, réponds sans appeler d'outil et explique en quelques phrases ce que tu as fait.
- Réponds en français.
## Environnement
- Système : {os}
- Les commandes sont exécutées par : {shell}
- Dossier de travail : {workdir}
- Pour lancer Python, utilise `python` (exemples : `python script.py`, `python -m pytest`).
## Skills
Une skill est une fiche d'instructions pour un type de tâche. Si la demande correspond à une skill, charge-la d'abord avec load_skill, puis suis ses instructions.
{skills}--- description: Méthode pour trouver et corriger un bug dans un script Python. --- # Corriger un bug 1. Lance le script avec run_command pour voir l'erreur ou le résultat faux. 2. Lis le fichier concerné avec read_file. 3. Trouve la cause exacte : la ligne fautive et pourquoi elle est fausse. 4. Corrige avec edit_file en changeant le moins de choses possible : old_text = les lignes fautives copiées exactement, new_text = les lignes corrigées. 5. Relance le script avec run_command pour vérifier que le bug a disparu. 6. Réponds en expliquant : la cause du bug, la correction, la vérification.
Ce qu'on doit observer
Sortie réelle du harness, avec un faux modèle qui joue la correction (le vrai qwen3.5:4b peut s'en écarter) :
vous > Corrige le bug de @calc.py
+ fichier joint : calc.py
[tour 1] edit_file {"path": "calc.py", "old_text": "return a - b", "new_text": "return a + b"}
--- calc.py (avant)
+++ calc.py (après)
@@ -1,4 +1,4 @@
def add(a, b):
- return a - b
+ return a + b
print(add(2, 3))
Écrire calc.py ? [o/N] o
→ Fichier calc.py modifié.
[tour 2] run_command {"command": "python calc.py"}
→ code de sortie : 0 (+4 lignes)
agent > La fonction add soustrayait : corrigé, le script affiche 5.
[contexte : 458 / 8192 tokens]Tests
def test_edit_file_cree_un_fichier(ctx):
questions = []
ctx.confirm = lambda question: questions.append(question) or True
resultat = execute_tool("edit_file", {"path": "dir/b.txt", "old_text": "", "new_text": "ligne 1\nligne 2\n"}, ctx)
assert resultat == "Fichier dir/b.txt créé."
assert (ctx.workdir / "dir" / "b.txt").read_text(encoding="utf-8") == "ligne 1\nligne 2\n"
assert "+ligne 1" in questions[0] # l'utilisateur a vu le diff
def test_edit_file_remplace_un_passage(ctx):
(ctx.workdir / "c.py").write_text("a = 1\nb = 2\nc = 3\n", encoding="utf-8")
resultat = execute_tool("edit_file", {"path": "c.py", "old_text": "b = 2", "new_text": "b = 20"}, ctx)
assert resultat == "Fichier c.py modifié."
assert (ctx.workdir / "c.py").read_text(encoding="utf-8") == "a = 1\nb = 20\nc = 3\n"
def test_edit_file_passage_introuvable(ctx):
(ctx.workdir / "c.py").write_text("a = 1\n", encoding="utf-8")
assert "old_text introuvable" in execute_tool("edit_file", {"path": "c.py", "old_text": "z = 9", "new_text": ""}, ctx)
def test_edit_file_passage_ambigu(ctx):
(ctx.workdir / "c.py").write_text("x = 0\nx = 0\n", encoding="utf-8")
assert "apparaît 2 fois" in execute_tool("edit_file", {"path": "c.py", "old_text": "x = 0", "new_text": "x = 1"}, ctx)
def test_edit_file_ne_recree_pas_un_fichier_existant(ctx):
(ctx.workdir / "c.py").write_text("a = 1\n", encoding="utf-8")
assert "existe déjà" in execute_tool("edit_file", {"path": "c.py", "old_text": "", "new_text": "b = 2"}, ctx)
assert (ctx.workdir / "c.py").read_text(encoding="utf-8") == "a = 1\n"Pièges à surveiller
- À vérifier avec le vrai Ollama : le compteur repose sur
prompt_eval_count. Si une partie du prompt vient du cache d'Ollama, elle pourrait ne pas être comptée : un compteur qui baisse alors que la conversation s'allonge le montrerait. old_textdoit être copié à l'identique : une indentation différente (tabulation au lieu d'espaces) donne « old_text introuvable », et le message demande au modèle de relire le fichier.- Un petit modèle peut mettre le fichier entier dans
old_text: ça fonctionne, mais on perd le gain de contexte. /clearremet le compteur à zéro et relit le prompt système.
Objectif
Ne plus confirmer les commandes sans danger (lancer les tests, git status) sans ouvrir la porte au reste.
Concept
Le dossier de travail protège les outils de fichiers, pas run_command : une commande shell peut écrire n'importe où (cd .., del, git push, pip install…). Seul un bac à sable (conteneur, Windows Sandbox, compte restreint) l'empêcherait vraiment : c'est hors de portée d'un agent minimal. La protection reste donc ta confirmation.
Pour ne pas confirmer cent fois python -m pytest, on ajoute une liste de commandes autorisées : dans allowed_commands du config.toml du projet, ou pendant la session avec /allow. Une commande passe sans question si elle commence par une commande de la liste et ne contient aucun opérateur shell : sinon git status && del *.py passerait pour git status.
python seul (python -c peut tout faire), ni git seul (git push), ni del, rm ou pip install.# Caractères qui permettent d'enchaîner, de rediriger ou de substituer des
# commandes (%VAR% est une variable sous Windows, $(...) une sous-commande).
SHELL_OPERATORS = ("&", "|", ";", ">", "<", "`", "$(", "\n", "%")
def is_allowed_command(command: str, allowed: list[str]) -> bool:
"""Vrai si la commande peut être lancée sans demander confirmation.
Elle doit commencer par une commande de la liste (« python -m pytest »
autorise « python -m pytest -q ») et ne contenir aucun opérateur shell :
sinon « git status && del *.py » passerait pour « git status ».
C'est un confort, pas une barrière de sécurité : la liste doit rester
limitée à des commandes sans danger.
"""
command = command.strip()
if any(operator in command for operator in SHELL_OPERATORS):
return False
return any(command == prefix or command.startswith(prefix + " ") for prefix in allowed)def run_command(ctx: ToolContext, command: str) -> str:
"""Lance une commande shell dans le dossier de travail, après confirmation."""
# Les commandes de la liste autorisée passent sans question (étape 14).
if not is_allowed_command(command, ctx.allowed_commands) and not ctx.confirm(f"Exécuter : {command}"):
return "Refusé par l'utilisateur : la commande n'a pas été exécutée."
# Force Python (s'il est lancé par la commande) à écrire en UTF-8,
# sinon Windows utilise cp1252 et les accents deviennent illisibles.
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
try:
result = subprocess.run(
command,
shell=True, # cmd.exe sous Windows, /bin/sh sous macOS
cwd=ctx.workdir,
capture_output=True,
stdin=subprocess.DEVNULL, # une commande ne peut pas attendre une saisie au clavier
encoding="utf-8",
errors="replace",
timeout=ctx.command_timeout,
env=env,
)
except subprocess.TimeoutExpired:
return f"Erreur : la commande a dépassé {ctx.command_timeout} s et a été arrêtée."
return (
f"code de sortie : {result.returncode}\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)def cmd_allow(session: Session, arg: str) -> str:
allowed = session.ctx.allowed_commands
if not arg:
return "Commandes lancées sans confirmation : " + (", ".join(allowed) or "aucune")
if any(operator in arg for operator in SHELL_OPERATORS):
return "Refusé : une commande autorisée ne doit contenir aucun opérateur shell (& | ; > < ...)."
allowed.append(arg)
return f"« {arg} » sera lancée sans confirmation jusqu'à la fin de la session."Ce qu'on doit observer
Sorties réelles du harness. python calc.py est dans allowed_commands : il est lancé sans question.
[tour 2] run_command {"command": "python calc.py"}
→ code de sortie : 0 (+4 lignes)vous > /allow git status && del calc.py Refusé : une commande autorisée ne doit contenir aucun opérateur shell (& | ; > < ...). vous > /allow python -m pytest « python -m pytest » sera lancée sans confirmation jusqu'à la fin de la session. vous > /allow Commandes lancées sans confirmation : python calc.py, python -m pytest
Tests
@pytest.mark.parametrize(
("commande", "attendu"),
[
("python -m pytest", True),
("python -m pytest -q tests", True),
("python -m pytestx", False), # préfixe d'un autre mot
("git status", True),
("git push", False),
("git status && del *.py", False), # enchaînement
("git status; rm -rf /", False),
("python -m pytest > sortie.txt", False), # redirection
("git status | more", False),
],
)
def test_commandes_autorisees(commande, attendu):
assert is_allowed_command(commande, ["python -m pytest", "git status"]) is attenduPièges à surveiller
- Une commande autorisée trop large (
python,git) supprime la confirmation pour bien plus que prévu. %est refusé dans une commande autorisée : sous Windows,%VAR%insère une variable.- Les commandes ajoutées avec
/allowsont oubliées en quittant ; celles deconfig.tomlvalent pour toutes les sessions du projet.
Objectif
Retrouver une conversation après avoir quitté llamagent, et pouvoir supprimer les anciennes.
Concept
Une conversation n'est qu'une liste de messages (étape 2) : la sauvegarder, c'est écrire cette liste dans un fichier JSON. Chaque conversation a son fichier dans .llamagent/sessions/ du projet, nommé d'après sa date de début. Il est réécrit après chaque demande : en cas d'arrêt brutal, on ne perd que la demande en cours.
- Le prompt système n'est pas sauvegardé : à la reprise, il est reconstruit avec l'
AGENTS.mdet les skills du moment. - On écrit dans un fichier temporaire, puis on le renomme : un Ctrl+C au mauvais moment ne laisse jamais un fichier à moitié écrit.
- Le titre (début de la première demande) est fixé une fois pour toutes, pour rester lisible après un compactage (étape 16).
/sessionsliste les conversations (1 = la plus récente),/resumeen reprend une,/deleteen supprime une après confirmation, etllamagent -creprend la dernière au démarrage./clearcommence une nouvelle conversation : l'ancienne reste sauvegardée./deletesupprime le fichier trouvé dans le dossier des sauvegardes, jamais un chemin lu dans le JSON : un fichier modifié à la main ne peut pas faire supprimer autre chose.
"""Sauvegarde des conversations, pour les reprendre plus tard (étape 15).
Chaque conversation est un fichier JSON dans .llamagent/sessions/ du
projet. Il est réécrit après chaque demande : en cas d'arrêt brutal, on
perd au plus la demande en cours.
"""
import json
from datetime import datetime
from pathlib import Path
from llamagent.config import PROJECT_DIR_NAME
from llamagent.context import estimate_messages
from llamagent.session import Session
def sessions_dir(workdir: Path) -> Path:
return workdir / PROJECT_DIR_NAME / "sessions"
def save_session(session: Session) -> None:
"""Enregistre la conversation, sans le prompt système (reconstruit à la reprise)."""
conversation = session.messages[1:]
if not any(m["role"] == "user" for m in conversation):
return # conversation vide : rien à sauvegarder
now = datetime.now()
if session.session_id is None:
session.session_id = now.strftime("%Y-%m-%d_%H-%M-%S")
session.remember_title()
data = {
"id": session.session_id,
"title": session.title,
"updated": now.isoformat(timespec="seconds"),
"model": session.config.model,
"messages": conversation,
}
folder = sessions_dir(session.config.workdir)
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{session.session_id}.json"
# On écrit dans un fichier temporaire, puis on le renomme : un Ctrl+C
# pendant l'écriture ne laisse jamais un fichier à moitié écrit.
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
temporary.replace(path)
def list_sessions(workdir: Path) -> list[dict]:
"""Les conversations sauvegardées, de la plus récente à la plus ancienne."""
folder = sessions_dir(workdir)
if not folder.is_dir():
return []
sessions = []
for path in folder.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
continue # fichier abîmé : on l'ignore plutôt que de planter
data["path"] = path # pour /delete : on supprime ce fichier-là, rien d'autre
sessions.append(data)
return sorted(sessions, key=lambda data: data.get("updated", ""), reverse=True)
def find_session(workdir: Path, ref: str) -> dict | None:
"""ref : numéro affiché par /sessions (1 = la plus récente) ou identifiant."""
sessions = list_sessions(workdir)
if ref.isdigit() and 1 <= int(ref) <= len(sessions):
return sessions[int(ref) - 1]
return next((data for data in sessions if data.get("id") == ref), None)
def resume_session(session: Session, data: dict) -> None:
"""Remplace la conversation en cours par une conversation sauvegardée."""
session.reset() # prompt système relu : AGENTS.md et skills à jour
session.messages += data["messages"]
session.session_id = data["id"]
session.title = data.get("title", "")
# Ollama ne mesurera le contexte qu'au prochain appel : en attendant, une estimation.
session.context_tokens = estimate_messages(session.messages)
session.context_estimated = True
def delete_session(data: dict) -> None:
data["path"].unlink(missing_ok=True)class Session:
...
def remember_title(self) -> None:
"""Retient le début de la première demande comme titre (étape 15).
Le titre est fixé une fois pour toutes : après un compactage, la
première demande a disparu, mais le titre reste le même.
"""
if not self.title:
first = next((m["content"] for m in self.messages if m["role"] == "user"), "")
self.title = first.splitlines()[0][:TITLE_LENGTH] if first else ""def cmd_sessions(session: Session, arg: str) -> str:
sessions = list_sessions(session.config.workdir)
if not sessions:
return "Aucune conversation sauvegardée dans ce projet."
lines = []
for number, data in enumerate(sessions, start=1):
current = "*" if data["id"] == session.session_id else " "
date = data.get("updated", "?").replace("T", " ")[:16]
lines.append(f"{current}{number:>3}. {date} {len(data.get('messages', [])):>3} messages {data.get('title', '')}")
return "\n".join(lines + ["", "/resume <numéro> pour reprendre, /delete <numéro> pour supprimer."])
def cmd_resume(session: Session, arg: str) -> str:
# Sans argument : la conversation la plus récente.
data = find_session(session.config.workdir, arg or "1")
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
resume_session(session, data)
return f"Conversation reprise : {data.get('title', '')} ({len(data['messages'])} messages)."
def cmd_delete(session: Session, arg: str) -> str:
if not arg:
return "Usage : /delete <numéro> (voir /sessions)"
data = find_session(session.config.workdir, arg)
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
if not session.ctx.confirm(f"Supprimer la conversation « {data.get('title', '')} » ?"):
return "Rien n'a été supprimé."
delete_session(data)
if data["id"] == session.session_id:
session.session_id = None # la conversation en cours sera sauvegardée dans un nouveau fichier
return f"Conversation supprimée : {data.get('title', '')}"def handle_request(session: Session, text: str) -> None:
"""Envoie une demande à l'agent et affiche sa réponse."""
for path in find_mentions(text):
print(color(f" + fichier joint : {path}", GREY))
try:
answer = session.ask(text)
except requests.RequestException as error:
print(f"Erreur de communication avec Ollama : {error}")
return
print(f"\nagent > {answer}")
print(context_status(session))
# Étape 16 : entre deux demandes, on compacte si le contexte est presque plein.
if needs_compaction(session):
print(color("Contexte presque plein : compactage automatique de la conversation...", YELLOW))
try:
compact(session)
print(context_status(session))
except requests.RequestException as error:
print(f"Compactage impossible : {error}")
# Étape 15 : la conversation est sauvegardée après chaque demande.
save_session(session)Dans main, l'option -c reprend la dernière conversation (voir cli.py dans le code complet).
Essayer
llamagent vous > Corrige le bug de @calc.py vous > /sessions vous > /quit llamagent -c vous > /sessions vous > /delete 1
Ce qu'on doit observer
Sorties réelles du harness (faux modèle) : la liste, puis une reprise avec -c et une suppression.
vous > /sessions * 1. 2026-10-25 22:03 2 messages Corrige le bug de @calc.py /resume <numéro> pour reprendre, /delete <numéro> pour supprimer.
llamagent — modèle qwen3.5:4b sur http://127.0.0.1:8781 Dossier de travail : …/p15 Conversation reprise : Corrige le bug de @calc.py (2 messages) Tape ta demande, ou /help pour les commandes. vous > /sessions * 1. 2026-10-25 22:03 2 messages Corrige le bug de @calc.py /resume <numéro> pour reprendre, /delete <numéro> pour supprimer. vous > /delete 1 Supprimer la conversation « Corrige le bug de @calc.py » ? [o/N] o Conversation supprimée : Corrige le bug de @calc.py vous > /sessions Aucune conversation sauvegardée dans ce projet.
Tests
"""Tests de la sauvegarde des conversations (history.py)."""
import json
import pytest
from llamagent.config import Config
from llamagent.history import delete_session, find_session, list_sessions, resume_session, save_session, sessions_dir
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def converse(session, *texts):
for text in texts:
session.messages.append({"role": "user", "content": text})
session.messages.append({"role": "assistant", "content": f"réponse à {text}"})
def test_conversation_vide_pas_sauvegardee(session, tmp_path):
save_session(session)
assert list_sessions(tmp_path) == []
def test_sauvegarde_et_liste(session, tmp_path):
converse(session, "Corrige le bug de calc.py\nmerci", "Et ajoute un test")
save_session(session)
sessions = list_sessions(tmp_path)
assert len(sessions) == 1
data = sessions[0]
assert data["id"] == session.session_id
assert data["title"] == "Corrige le bug de calc.py" # première ligne de la première demande
assert data["model"] == "qwen3.5:4b"
assert [m["role"] for m in data["messages"]] == ["user", "assistant", "user", "assistant"] # sans le prompt système
def test_meme_fichier_tant_que_la_conversation_continue(session, tmp_path):
converse(session, "Première demande")
save_session(session)
converse(session, "Deuxième demande")
save_session(session)
assert len(list(sessions_dir(tmp_path).glob("*.json"))) == 1
assert len(list_sessions(tmp_path)[0]["messages"]) == 4
def test_clear_commence_un_nouveau_fichier(session, tmp_path):
converse(session, "Première conversation")
save_session(session)
session.reset()
assert session.session_id is None
def test_reprise(session, tmp_path):
converse(session, "Corrige le bug")
save_session(session)
saved_id = session.session_id
session.reset()
(tmp_path / "AGENTS.md").write_text("Nouvelle consigne.", encoding="utf-8")
resume_session(session, find_session(tmp_path, "1"))
assert session.session_id == saved_id
assert [m["role"] for m in session.messages] == ["system", "user", "assistant"]
assert session.messages[0]["content"].endswith("Nouvelle consigne.") # prompt système relu
assert session.context_tokens > 0 # estimation en attendant Ollama
def test_recherche_par_numero_ou_identifiant(tmp_path):
folder = sessions_dir(tmp_path)
folder.mkdir(parents=True)
for name, updated in (("ancienne", "2026-10-01T10:00:00"), ("recente", "2026-10-25T10:00:00")):
data = {"id": name, "title": name, "updated": updated, "model": "m", "messages": []}
(folder / f"{name}.json").write_text(json.dumps(data), encoding="utf-8")
assert find_session(tmp_path, "1")["id"] == "recente"
assert find_session(tmp_path, "2")["id"] == "ancienne"
assert find_session(tmp_path, "ancienne")["id"] == "ancienne"
assert find_session(tmp_path, "3") is None
def test_fichier_abime_ignore(session, tmp_path):
converse(session, "Demande")
save_session(session)
(sessions_dir(tmp_path) / "abime.json").write_text("{pas du json", encoding="utf-8")
assert len(list_sessions(tmp_path)) == 1
def test_suppression(session, tmp_path):
converse(session, "Demande")
save_session(session)
delete_session(find_session(tmp_path, "1"))
assert list_sessions(tmp_path) == []Pièges à surveiller
- Une sauvegarde contient tout : contenu des fichiers lus, sorties des commandes.
/initajoute un.llamagent/.gitignorequi exclutsessions/de git ; sans/init, l'ajouter à la main. - Reprendre une conversation avec un autre modèle fonctionne, mais il relit des appels d'outils qu'il n'a pas faits.
- Les sauvegardes s'accumulent :
/delete, ou vider le dossier.llamagent/sessions/. - Une conversation reprise peut être longue : le compteur affiche une estimation (
≈) jusqu'au prochain appel, et/compact(étape 16) la raccourcit.
Objectif
Voir ce qui remplit le contexte, et le libérer en résumant la conversation, à la demande ou automatiquement.
Concept
L'étape 13 affiche le total mesuré par Ollama. Pour savoir ce qui le remplit, /context estime la part de chaque catégorie de messages à partir du nombre de caractères (environ 4 caractères par token). C'est une approximation : sans le tokenizer du modèle, et sans ajouter de dépendance, on ne peut pas faire mieux. La vraie mesure reste celle d'Ollama.
Compacter, c'est remplacer la conversation par un résumé. On transforme la conversation en texte simple (résultats d'outils et arguments raccourcis à 300 caractères), on demande au modèle de la résumer avec les consignes de prompts/COMPACT.md, sans outils, puis on ne garde que trois messages : le prompt système, le résumé (en message utilisateur) et un court accusé de réception de l'assistant, pour que les rôles continuent d'alterner.
/compactcompacte à la demande ; le compactage est automatique entre deux demandes quand le contexte dépasse 80 % (COMPACT_THRESHOLD).- Le texte à résumer doit lui-même tenir dans le contexte : il est limité à la moitié, en coupant le milieu.
- Après un compactage ou une reprise, le compteur est une estimation, marquée
≈, jusqu'au prochain appel.
# Compactage automatique quand le contexte est rempli à 80 % (étape 16). COMPACT_THRESHOLD = 0.8
Tu résumes une conversation entre un utilisateur et un agent de code, pour que l'agent puisse continuer le travail sans la relire. Garde : - ce que l'utilisateur a demandé, et ce qui reste à faire ; - les fichiers lus, créés ou modifiés, et ce qui a changé dans chacun ; - les décisions prises et les erreurs rencontrées ; - les commandes utiles (tests, lancement). Règles : - 15 lignes au maximum, en français. - Pas de code complet : seulement les noms de fichiers, de fonctions et les points importants. - Écris uniquement le résumé, sans introduction.
"""Mesurer et compacter le contexte (étape 16).
Mesurer : Ollama donne la taille totale après chaque appel (étape 13). Pour
savoir ce qui remplit le contexte, on estime la part de chaque catégorie de
messages à partir du nombre de caractères.
Compacter : quand le contexte est presque plein, on demande au modèle de
résumer la conversation, puis on remplace toute la conversation par ce
résumé. Le prompt système, lui, est gardé tel quel.
"""
import json
from llamagent import llm
from llamagent.config import COMPACT_THRESHOLD, PROMPTS_DIR
from llamagent.guards import truncate
from llamagent.tools import tool_schemas
# Approximation : environ 4 caractères par token, en français comme en code.
# Ce n'est qu'une estimation ; la vraie mesure vient d'Ollama.
CHARS_PER_TOKEN = 4
# Dans le texte à résumer, chaque résultat d'outil ou argument est raccourci.
TRANSCRIPT_ITEM_CHARS = 300
def estimate_tokens(text: str) -> int:
return len(text) // CHARS_PER_TOKEN
def message_text(message: dict) -> str:
"""Tout le texte d'un message, appels d'outils compris."""
calls = message.get("tool_calls") or []
return (message.get("content") or "") + (json.dumps(calls, ensure_ascii=False) if calls else "")
def estimate_messages(messages: list[dict]) -> int:
"""Estimation de la place occupée par des messages et par les schémas des outils."""
text = "".join(message_text(m) for m in messages) + json.dumps(tool_schemas(), ensure_ascii=False)
return estimate_tokens(text)
def context_report(session) -> str:
"""Ce qui occupe le contexte, catégorie par catégorie (commande /context)."""
parts = {
"prompt système": 0,
"schémas des outils": estimate_tokens(json.dumps(tool_schemas(), ensure_ascii=False)),
"vos messages": 0,
"réponses du modèle": 0,
"résultats d'outils": 0,
}
category = {"system": "prompt système", "user": "vos messages", "assistant": "réponses du modèle", "tool": "résultats d'outils"}
for message in session.messages:
parts[category[message["role"]]] += estimate_tokens(message_text(message))
total = sum(parts.values())
size = session.config.num_ctx
filled = min(20, round(20 * total / size))
lines = [f"{name:<20} ≈ {tokens:>6} tokens" for name, tokens in parts.items()]
lines += [
f"{'total estimé':<20} ≈ {total:>6} tokens [{'#' * filled}{'-' * (20 - filled)}] {100 * total // size} % de {size}",
f"dernier total : {session.context_tokens} tokens ({'estimation' if session.context_estimated else 'mesuré par Ollama'})",
f"compactage automatique à {int(COMPACT_THRESHOLD * 100)} %, ou maintenant avec /compact",
]
return "\n".join(lines)
def transcript(messages: list[dict]) -> str:
"""La conversation en texte simple, pour la faire résumer par le modèle."""
lines = []
for message in messages:
role = message["role"]
if role == "user":
lines.append(f"UTILISATEUR : {truncate(message['content'], 4 * TRANSCRIPT_ITEM_CHARS)}")
elif role == "assistant":
if message.get("content"):
lines.append(f"AGENT : {message['content']}")
for call in message.get("tool_calls") or []:
arguments = json.dumps(call["function"].get("arguments", {}), ensure_ascii=False)
lines.append(f"AGENT appelle {call['function']['name']} {truncate(arguments, TRANSCRIPT_ITEM_CHARS)}")
elif role == "tool":
lines.append(f"RÉSULTAT de {message.get('tool_name', '?')} : {truncate(message['content'], TRANSCRIPT_ITEM_CHARS)}")
return "\n".join(lines)
def needs_compaction(session) -> bool:
return session.context_tokens >= COMPACT_THRESHOLD * session.config.num_ctx
def compact(session) -> str:
"""Remplace la conversation par un résumé écrit par le modèle, et renvoie ce résumé."""
conversation = session.messages[1:]
if not conversation:
return ""
# Le texte à résumer doit lui-même tenir dans le contexte : au plus la
# moitié, en coupant le milieu (truncate garde le début et la fin).
text = truncate(transcript(conversation), session.config.num_ctx * CHARS_PER_TOKEN // 2)
instructions = (PROMPTS_DIR / "COMPACT.md").read_text(encoding="utf-8")
# Appel sans outils : on veut du texte, pas des tool calls.
reply = llm.chat(session.config, [{"role": "system", "content": instructions}, {"role": "user", "content": text}])
summary = (reply.get("content") or "").strip()
session.remember_title() # avant que la première demande ne disparaisse
session.messages = [
session.messages[0], # le prompt système est gardé tel quel
{"role": "user", "content": f"Résumé de notre conversation jusqu'ici (le début a été compacté) :\n\n{summary}"},
{"role": "assistant", "content": "Compris, je reprends à partir de ce résumé."},
]
session.context_tokens = estimate_messages(session.messages)
session.context_estimated = True
return summarydef cmd_context(session: Session, arg: str) -> str:
return context_report(session)
def cmd_compact(session: Session, arg: str) -> str:
if len(session.messages) <= 1:
return "Rien à compacter : la conversation est vide."
summary = compact(session)
save_session(session) # le fichier sauvegardé contient désormais le résumé
return f"Conversation compactée. Le modèle repart de ce résumé :\n\n{summary}"Essayer
vous > /context vous > /compact vous > /context llamagent --num-ctx 2048 "Décris @data1.csv et @data2.csv"
Ce qu'on doit observer
Sorties réelles du harness. Le résumé est écrit ici par un faux modèle ; le vrai qwen3.5:4b écrira le sien, à vérifier. De même, le « dernier total » vient du faux serveur, qui compte autrement qu'Ollama : l'écart avec l'estimation n'a pas de sens ici. Avec le vrai Ollama, c'est cette comparaison qui dira si l'estimation est fiable.
vous > /context prompt système ≈ 347 tokens schémas des outils ≈ 471 tokens vos messages ≈ 29 tokens réponses du modèle ≈ 65 tokens résultats d'outils ≈ 19 tokens total estimé ≈ 931 tokens [##------------------] 11 % de 8192 dernier total : 436 tokens (mesuré par Ollama) compactage automatique à 80 %, ou maintenant avec /compact vous > /compact Conversation compactée. Le modèle repart de ce résumé : - Demande : corriger le bug de calc.py. - calc.py : add() faisait a - b, corrigé en a + b avec edit_file. - Vérifié avec `python calc.py` : affiche 5. - Reste à faire : rien. vous > /context prompt système ≈ 347 tokens schémas des outils ≈ 471 tokens vos messages ≈ 60 tokens réponses du modèle ≈ 10 tokens résultats d'outils ≈ 0 tokens total estimé ≈ 888 tokens [##------------------] 10 % de 8192 dernier total : 890 tokens (estimation) compactage automatique à 80 %, ou maintenant avec /compact
Compactage automatique, avec un contexte réduit à 2048 tokens et deux gros fichiers joints :
vous > Décris @data1.csv et @data2.csv + fichier joint : data1.csv + fichier joint : data2.csv agent > data1.csv et data2.csv contiennent des mesures de température, une par ligne. [contexte : 2429 / 2048 tokens] plein : Ollama a coupé le début de la conversation Contexte presque plein : compactage automatique de la conversation... [contexte : ≈ 882 / 2048 tokens]
Tests
"""Tests de la mesure et du compactage du contexte (context.py)."""
import pytest
from llamagent import llm
from llamagent.config import Config
from llamagent.context import compact, context_report, estimate_tokens, needs_compaction, transcript
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
session = create_session(config, confirm=lambda question: True)
session.messages += [
{"role": "user", "content": "Corrige le bug de calc.py"},
{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "calc.py"}}}]},
{"role": "tool", "tool_name": "read_file", "content": "x" * 5000},
{"role": "assistant", "content": "C'est corrigé."},
]
return session
def test_estimation():
assert estimate_tokens("a" * 400) == 100
def test_rapport_par_categorie(session):
rapport = context_report(session)
for categorie in ("prompt système", "schémas des outils", "vos messages", "réponses du modèle", "résultats d'outils", "total estimé"):
assert categorie in rapport
ligne = next(l for l in rapport.splitlines() if l.startswith("résultats d'outils"))
assert ligne.endswith("≈ 1250 tokens") # 5000 caractères / 4
def test_transcription_raccourcit_les_resultats(session):
texte = transcript(session.messages)
assert "UTILISATEUR : Corrige le bug de calc.py" in texte
assert 'AGENT appelle read_file {"path": "calc.py"}' in texte
assert "RÉSULTAT de read_file :" in texte
assert len(texte) < 1000 # le résultat de 5000 caractères est tronqué
assert "prompt" not in texte.lower() # le prompt système n'est pas résumé
def test_compactage(monkeypatch, session):
envoyes = []
def faux_chat(config, messages, tools=None):
envoyes.append((messages, tools))
return {"role": "assistant", "content": " calc.py : add corrigée (a + b). "}
monkeypatch.setattr(llm, "chat", faux_chat)
system_prompt = session.messages[0]
resume = compact(session)
assert resume == "calc.py : add corrigée (a + b)."
messages, tools = envoyes[0]
assert tools is None # le résumé est demandé sans outils
assert "Tu résumes une conversation" in messages[0]["content"] # prompts/COMPACT.md
assert "UTILISATEUR : Corrige le bug de calc.py" in messages[1]["content"]
assert session.messages[0] is system_prompt
assert [m["role"] for m in session.messages] == ["system", "user", "assistant"]
assert "calc.py : add corrigée" in session.messages[1]["content"]
assert 0 < session.context_tokens < 2000
assert session.context_estimated
assert "(estimation)" in context_report(session)
def test_seuil_de_compactage(session):
session.context_tokens = 6000
assert not needs_compaction(session) # 6000 / 8192 = 73 %
session.context_tokens = 6600
assert needs_compaction(session) # 80 %Pièges à surveiller
- Un compactage perd des détails : le contenu exact des fichiers lus et les sorties des commandes ne sont plus dans le contexte, et le modèle devra relire les fichiers. La sauvegarde est réécrite avec le résumé : la conversation complète n'est plus récupérable.
- Un résumé écrit par un modèle 4B peut être incomplet ou inexact : sur une tâche importante, lancer
/compactsoi-même et relire le résumé. - Le compactage automatique n'a lieu qu'entre deux demandes : une seule demande très longue (beaucoup d'appels d'outils) peut encore déborder en cours de route.
- Dans l'exemple ci-dessus, le contexte était déjà plein avant le compactage : Ollama avait coupé le début de la dernière requête, prompt système compris. Avec
num_ctx = 8192, le seuil de 80 % laisse plus de marge. - L'estimation à 4 caractères par token est grossière : le code, les accents et les espaces en consomment plus ou moins. Comparer avec le « dernier total » mesuré par Ollama.
Objectif
Une interface propre à llamagent, pensée pour apprendre : une mascotte au démarrage, et chaque tour de la boucle agentique rendu visible. Sans ajouter de dépendance.
Concept
Un terminal comprend des séquences d'échappement ANSI : \033[1m passe en gras, \033[38;5;38m choisit la couleur 38 de la palette de 256 couleurs, \033[0m revient au style normal, \r ramène le curseur en début de ligne et \033[K efface la fin de la ligne. Tout le module ui.py repose sur ces quelques codes. Une bibliothèque comme rich ferait la même chose en quelques lignes, mais au prix d'une dépendance : ici, on voit comment ça marche.
- Montrer la boucle : chaque tour est annoncé (« ── tour 2 ── »), puis trois lignes donnent la parole aux trois acteurs. Le modèle demande un outil, le harness dit ce qu'il en fait (vérifier le chemin, demander ta confirmation, bloquer un appel répété…), l'outil renvoie son résultat. Le dernier tour montre pourquoi la boucle s'arrête : le modèle répond sans demander d'outil. C'est le schéma du haut de la page, en vrai.
- La phrase du harness vient de
describe_harness(), dansagent.py: elle ne décide rien, elle décrit ce que les garde-fous vont faire. - La mascotte : un petit robot en caractères de dessin (
┌─┴─┐), antenne orange et tête bleu-vert, les couleurs du site. Elle accueille l'utilisateur à côté du titre, au-dessus des réglages de la session. - Animation d'attente, dans la colonne du modèle : un fil d'exécution (thread) réécrit la même ligne grâce à
\r, puis l'efface avec\033[K. La session la reçoit sous forme d'un gestionnaire de contexte (session.waiting) : par défaut il ne fait rien, ce qui garde les tests simples. - Réponses : un rendu Markdown minimal (titres, gras,
code, listes, blocs de code), le diff en couleurs aligné sous le harness, et une jauge du contexte utilisé. - Repli : si la sortie n'est pas un terminal (redirection vers un fichier) ou si
NO_COLORest défini, tout s'affiche en texte simple ; si la console ne sait pas afficher les caractères de dessin, la mascotte passe en ASCII.

"""L'interface de llamagent, sans dépendance (étape 17).
Deux idées :
- une mascotte, dessinée pour le projet, accueille l'utilisateur ;
- chaque tour de la boucle agentique est affiché avec ses trois acteurs :
le modèle (ce qu'il demande), le harness (ce que les garde-fous en font)
et l'outil (ce qu'il renvoie). On voit la boucle du tutoriel tourner.
Tout passe par des codes ANSI : « \\033[...m » change la couleur ou le style
du texte qui suit, « \\033[0m » revient au style normal. Les couleurs sont
celles du site (bleu-vert et orange), prises dans la palette de 256 couleurs.
Si la sortie n'est pas un terminal (redirection vers un fichier) ou si
NO_COLOR est défini, on affiche du texte simple.
"""
import itertools
import json
import os
import re
import shutil
import sys
import threading
import time
from contextlib import contextmanager
def _supports_style() -> bool:
return sys.stdout.isatty() and "NO_COLOR" not in os.environ
def _supports_unicode() -> bool:
"""Vrai si la console sait afficher la mascotte et les traits."""
try:
"┌┴┐│└┬┘─●▬█░►…".encode(sys.stdout.encoding or "ascii")
return True
except (UnicodeEncodeError, LookupError):
return False
STYLE = _supports_style()
UNICODE = _supports_unicode()
# Codes de style. « 38;5;N » choisit la couleur N de la palette de 256.
BOLD = "1"
TEAL = "38;5;38" # le bleu-vert du site
ORANGE = "38;5;208" # l'orange du site
GREY = "38;5;245"
LIGHT = "38;5;252"
GREEN = "38;5;114"
RED = "38;5;203"
YELLOW = "38;5;221"
CODE = "38;5;117"
# La mascotte : un petit robot, antenne orange, tête bleu-vert.
MASCOT = [
" ● ",
" ┌─┴─┐",
" │o o│",
" │ ▬ │",
" └┬─┬┘",
]
MASCOT_ASCII = [
" o ",
" .-+-.",
" |o o|",
" | = |",
" '+-+'",
]
# Les trois acteurs de la boucle, plus l'utilisateur.
ROLE_STYLE = {"vous": ORANGE, "modèle": TEAL, "harness": LIGHT, "outil": GREEN}
ROLE_WIDTH = 9 # assez pour « harness » et deux espaces
INDENT = " " * (2 + ROLE_WIDTH) # pour aligner les lignes suivantes sous le texte
ANSI_CODE = re.compile(r"\033\[[0-9;]*[mK]")
def style(text: str, *codes: str) -> str:
"""Entoure le texte des codes ANSI demandés (ou le laisse tel quel)."""
if not STYLE or not codes:
return text
return f"\033[{';'.join(codes)}m{text}\033[0m"
def visible_len(text: str) -> int:
"""Longueur affichée d'un texte : les codes ANSI ne prennent pas de place."""
return len(ANSI_CODE.sub("", text))
def symbol(fancy: str, plain: str) -> str:
return fancy if UNICODE else plain
def shorten(text: str, width: int) -> str:
"""Raccourcit par le début (on garde la fin d'un chemin, la plus parlante)."""
if len(text) <= width:
return text
return symbol("…", "...") + text[-(width - 1):]
# ─────────────────────────── Démarrage ───────────────────────────
def mascot() -> list[str]:
lines = []
for number, line in enumerate(MASCOT if UNICODE else MASCOT_ASCII):
chars = []
for char in line:
if char == " ":
chars.append(char)
elif number == 0:
chars.append(style(char, ORANGE, BOLD)) # l'antenne
elif char == "o":
chars.append(style(char, BOLD)) # les yeux
else:
chars.append(style(char, TEAL))
lines.append("".join(chars))
return lines
def banner(rows: list[tuple[str, str]]) -> str:
"""La mascotte, le titre, puis les réglages de la session."""
dot = symbol(" · ", " - ")
hint = (
style("/help", ORANGE) + style(" commandes", GREY) + style(dot, GREY)
+ style("@fichier", ORANGE) + style(" joindre un fichier", GREY) + style(dot, GREY)
+ style("/quit", ORANGE) + style(" quitter", GREY)
)
beside = ["", style("llamagent", BOLD, TEAL), style("un agent de code minimal autour d’Ollama", GREY), "", hint]
top = [f"{line} {text}".rstrip() for line, text in zip(mascot(), beside)]
width = shutil.get_terminal_size().columns - ROLE_WIDTH - 4
label_width = max(len(label) for label, _ in rows)
info_lines = [f" {style(label.ljust(label_width), GREY)} {shorten(value, width)}" for label, value in rows]
return "\n" + "\n".join(top) + "\n\n" + "\n".join(info_lines)
# ─────────────────────────── La boucle ───────────────────────────
def role(name: str) -> str:
"""L'étiquette d'un acteur, en couleur, sur une largeur fixe."""
return " " + style(name.ljust(ROLE_WIDTH), ROLE_STYLE[name], BOLD)
def role_line(name: str, text: str, *codes: str) -> str:
return role(name) + style(text, *codes)
def prompt() -> str:
"""L'invite : l'utilisateur est le premier acteur de la conversation."""
return "\n" + role("vous")
def rule(label: str) -> str:
"""Un trait de séparation avec un titre : « ── tour 2 ─────── »."""
h = symbol("─", "-")
width = min(64, shutil.get_terminal_size().columns - 2)
start = f"{h * 2} {label} "
return style(start + h * max(3, width - len(start)), GREY)
def tour_header(tour: int) -> str:
return "\n" + rule(f"tour {tour}")
def call_text(name: str, arguments) -> str:
"""« edit_file(calc.py, …) » : le nom de l'outil et son premier argument."""
if isinstance(arguments, dict) and arguments:
values = list(arguments.values())
detail = str(values[0]) + (", " + symbol("…", "...") if len(values) > 1 else "")
elif isinstance(arguments, dict):
detail = ""
else:
detail = json.dumps(arguments, ensure_ascii=False)
if len(detail) > 60:
detail = detail[:59] + symbol("…", "...")
return f"{name}({detail})"
def model_asks(name: str, arguments) -> str:
return role_line("modèle", "demande ") + style(call_text(name, arguments), BOLD)
def tool_result(result: str, truncated: bool = False) -> str:
is_error = result.startswith(("Erreur", "Refusé"))
lines = result.splitlines() or [""]
preview = lines[0][:90] + (f" (+{len(lines) - 1} lignes)" if len(lines) > 1 else "")
if truncated:
preview += " [tronqué]"
return role_line("outil", preview, RED if is_error else GREY)
def answer_header(tour: int) -> str:
calls = f"{tour} appel{'s' if tour > 1 else ''} au modèle"
return "\n" + rule(f"réponse {symbol('·', '-')} {calls}")
@contextmanager
def thinking(label: str = "réfléchit"):
"""Animation dans la colonne du modèle pendant qu'on attend sa réponse.
Elle tourne dans un fil d'exécution (thread) séparé : le programme
principal, lui, est bloqué dans la requête HTTP. « \\r » ramène le
curseur en début de ligne, « \\033[K » efface la ligne à la fin.
"""
if not STYLE:
yield
return
stop = threading.Event()
def animate() -> None:
start = time.monotonic()
for dots in itertools.cycle([" ", ". ", ".. ", "..."]):
elapsed = time.monotonic() - start
sys.stdout.write(f"\r{role('modèle')}{style(f'{label}{dots} {elapsed:.0f} s', GREY)}")
sys.stdout.flush()
if stop.wait(0.3): # attend 0,3 s, ou s'arrête si on le lui demande
break
sys.stdout.write("\r\033[K")
sys.stdout.flush()
thread = threading.Thread(target=animate, daemon=True)
thread.start()
try:
yield
finally:
stop.set()
thread.join()
def confirm(question: str) -> bool:
"""Comme guards.ask_confirmation, avec le diff en couleurs, aligné sous le harness."""
lines = question.splitlines() or [""]
for line in lines[:-1]:
if line.startswith(("+++", "---")):
line = style(line, GREY)
elif line.startswith("+"):
line = style(line, GREEN)
elif line.startswith("-"):
line = style(line, RED)
elif line.startswith("@@"):
line = style(line, CODE)
print(INDENT + line)
reply = input(f"{INDENT}{style('?', ORANGE, BOLD)} {lines[-1]} {style('[o/N]', BOLD)} ")
return reply.strip().lower() in ("o", "oui")
# ─────────────────────────── Réponse ───────────────────────────
INLINE_CODE = re.compile(r"`([^`]+)`")
BOLD_TEXT = re.compile(r"\*\*(.+?)\*\*")
BULLET = re.compile(r"^(\s*)[-*] (.*)")
def render_markdown(text: str) -> str:
"""Un rendu très simple du Markdown des réponses : titres, gras, `code`,
listes et blocs de code. Sans couleurs, le texte reste tel quel."""
if not STYLE:
return text
lines, in_code = [], False
for line in text.splitlines():
if line.strip().startswith("```"):
in_code = not in_code # les lignes ``` elles-mêmes ne sont pas affichées
continue
if in_code:
lines.append(style(symbol("│ ", "| "), GREY) + style(line, CODE))
continue
if line.startswith("#"):
lines.append(style(line.lstrip("#").strip(), BOLD, TEAL))
continue
bullet = BULLET.match(line)
if bullet:
line = f"{bullet.group(1)}{style(symbol('►', '-'), ORANGE)} {bullet.group(2)}"
line = BOLD_TEXT.sub(lambda match: style(match.group(1), BOLD), line)
line = INLINE_CODE.sub(lambda match: style(match.group(1), CODE), line)
lines.append(line)
return "\n".join(lines)
def answer(text: str) -> str:
"""La réponse finale, décalée sous le trait « réponse »."""
return "\n".join(" " + line for line in render_markdown(text).splitlines())
def context_gauge(model: str, used: int, size: int, estimated: bool) -> str:
"""Une jauge de remplissage du contexte, affichée sous chaque réponse."""
ratio = min(1.0, used / size)
filled = round(ratio * 10)
bar = symbol("█", "#") * filled + symbol("░", "-") * (10 - filled)
level = RED if used >= size else YELLOW if used >= 0.75 * size else TEAL
dot = symbol(" · ", " - ")
approx = symbol("≈", "~") if estimated else ""
text = style(f" contexte {bar}", level) + style(f" {approx}{used} / {size} tokens ({int(ratio * 100)} %){dot}{model}", GREY)
if used >= size:
text += style(" plein : Ollama a coupé le début de la conversation", RED)
return text
# ─────────────────────────── Messages ───────────────────────────
def info(text: str) -> str:
"""Un message secondaire, aligné sous le texte (fichier joint...)."""
return style(f"{INDENT}{text}", GREY)
def warning(text: str) -> str:
return style(f" {text}", YELLOW)
def error(text: str) -> str:
return style(f" {symbol('×', 'x')} {text}", RED)
def indent(text: str) -> str:
"""Décale un bloc de texte (sortie des commandes /...)."""
return "\n".join(" " + line for line in text.splitlines())def describe_harness(name: str, arguments, ctx: ToolContext, repeated: bool) -> str:
"""Ce que le harness fait de la demande du modèle, en une phrase (étape 17).
Seulement pour l'affichage : on rend visible le rôle du harness entre
le modèle, qui demande, et l'outil, qui exécute.
"""
if repeated:
return "appel identique au précédent : pas exécuté, le modèle est prévenu"
if name not in TOOLS:
return "outil inconnu : l'erreur est renvoyée au modèle"
if name == "edit_file":
return "vérifie le chemin, montre le diff et demande ta confirmation"
if name == "run_command":
command = arguments.get("command", "") if isinstance(arguments, dict) else ""
if is_allowed_command(command, ctx.allowed_commands):
return "commande autorisée : lancée sans confirmation"
return "demande ta confirmation avant de lancer la commande"
if name == "load_skill":
return "vérifie que la skill existe et la charge"
return f"vérifie le chemin et exécute {name}"
def run_turn(chat: ChatFunction, messages: list[dict], ctx: ToolContext, max_tours: int = MAX_TOURS) -> str:
"""Traite le dernier message utilisateur et renvoie la réponse finale.
`messages` est modifiée sur place : on y ajoute les messages de
l'assistant et les résultats des outils, pour garder tout l'historique.
L'affichage montre chaque tour : le modèle demande, le harness décide,
l'outil répond.
"""
previous_call = None
for tour in range(1, max_tours + 1):
print(ui.tour_header(tour))
reply = chat(messages, tool_schemas())
# 1) On garde le message de l'assistant TEL QUEL, avec ses tool_calls :
# au prochain appel, le modèle doit voir ce qu'il a demandé.
messages.append(reply)
tool_calls = reply.get("tool_calls") or []
if not tool_calls:
print(ui.role_line("modèle", "répond sans demander d'outil : fin de la boucle"))
print(ui.answer_header(tour))
return reply.get("content", "")
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"].get("arguments", {})
print(ui.model_asks(name, arguments))
# Détection de boucle : le même appel deux fois de suite ne
# donnera pas un résultat différent, on le signale au modèle.
signature = (name, json.dumps(arguments, sort_keys=True, ensure_ascii=False))
repeated = signature == previous_call
print(ui.role_line("harness", describe_harness(name, arguments, ctx, repeated)))
if repeated:
result = (
f"Erreur : tu viens déjà d'appeler {name} avec exactement ces arguments. "
"Le résultat sera le même : change d'approche ou donne ta réponse finale."
)
else:
result = execute_tool(name, arguments, ctx)
previous_call = signature
shortened = truncate(result, MAX_OUTPUT_CHARS)
print(ui.tool_result(shortened, truncated=len(shortened) < len(result)))
# 2) Puis le résultat de l'outil, avec le rôle "tool".
messages.append({"role": "tool", "tool_name": name, "content": shortened})
print(ui.role_line("harness", f"limite de {max_tours} tours atteinte : la boucle s'arrête", ui.RED))
return f"Arrêt : limite de {max_tours} tours atteinte sans réponse finale."class Session:
...
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
"""La fonction `chat` donnée à la boucle. Elle lit self.config à chaque
appel : un /model ou un /temp s'applique dès l'appel suivant."""
with self.waiting():
data = llm.chat_full(self.config, messages, tools)
# Tokens lus + tokens générés : la place occupée dans le contexte.
self.context_tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
self.context_estimated = False
return data["message"]"""Point d'entrée de la ligne de commande : `llamagent`.
llamagent # mode conversation dans le dossier courant
llamagent "corrige le bug..." # une seule demande, puis on quitte
llamagent -m qwen3.5:9b -t 0.5 # options : voir llamagent --help
C'est ici qu'on assemble le harness : configuration, session (prompt
système, outils, garde-fous), commandes /..., puis la boucle agentique.
Tout l'affichage passe par ui.py (étape 17).
"""
import argparse
import os
import requests
from llamagent import ui
from llamagent.commands import execute_command
from llamagent.config import PROJECT_DIR_NAME, load_config
from llamagent.context import compact, needs_compaction
from llamagent.history import find_session, resume_session, save_session
from llamagent.session import Session, create_session, find_mentions, load_project_instructions
from llamagent.tools import available_skills
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="llamagent", description="Un agent de code minimal autour d'Ollama.")
parser.add_argument("demande", nargs="*", help="demande à traiter avant de quitter (sinon : mode conversation)")
parser.add_argument("-m", "--model", help="modèle Ollama, ex. qwen3.5:4b")
parser.add_argument("-t", "--temperature", type=float, help="température, ex. 0.2")
parser.add_argument("--num-ctx", type=int, help="taille du contexte en tokens, ex. 8192")
parser.add_argument("--url", help="adresse du serveur Ollama")
parser.add_argument("-c", "--continue", dest="resume", action="store_true", help="reprendre la dernière conversation du projet")
return parser.parse_args(argv)
def context_status(session: Session) -> str:
"""La jauge de contexte affichée sous chaque réponse."""
c = session.config
return ui.context_gauge(c.model, session.context_tokens, c.num_ctx, session.context_estimated)
def handle_request(session: Session, text: str) -> None:
"""Envoie une demande à l'agent et affiche sa réponse."""
for path in find_mentions(text):
print(ui.info(f"@{path} joint au message"))
try:
answer = session.ask(text)
except requests.RequestException as error:
print(ui.error(f"Erreur de communication avec Ollama : {error}"))
return
print(ui.answer(answer))
print("\n" + context_status(session))
# Étape 16 : entre deux demandes, on compacte si le contexte est presque plein.
if needs_compaction(session):
print(ui.warning("Contexte presque plein : compactage automatique de la conversation..."))
try:
compact(session)
print(context_status(session))
except requests.RequestException as error:
print(ui.error(f"Compactage impossible : {error}"))
# Étape 15 : la conversation est sauvegardée après chaque demande.
save_session(session)
def project_summary(session: Session) -> str:
"""Une ligne qui résume ce que le projet apporte : AGENTS.md, skills, réglages."""
workdir = session.config.workdir
has_instructions = bool(load_project_instructions(workdir))
parts = ["AGENTS.md"] if has_instructions else []
parts.append(f"{len(available_skills(session.ctx.skills_dirs))} skills")
if (workdir / PROJECT_DIR_NAME / "config.toml").is_file():
parts.append("config.toml")
if not has_instructions:
parts.append("/init pour préparer le projet")
return ui.symbol(" · ", " - ").join(parts)
def main() -> None:
if os.name == "nt":
os.system("") # active les codes ANSI (couleurs) dans les consoles Windows
args = parse_args()
config = load_config({"model": args.model, "temperature": args.temperature, "num_ctx": args.num_ctx, "url": args.url})
session = create_session(config, confirm=ui.confirm)
session.waiting = ui.thinking # animation pendant l'attente du modèle
rows = [("modèle", config.model), ("serveur", config.ollama_url), ("dossier", str(config.workdir)), ("projet", project_summary(session))]
if args.resume:
data = find_session(config.workdir, "1")
if data is None:
rows.append(("reprise", "aucune conversation sauvegardée : nouvelle conversation"))
else:
resume_session(session, data)
rows.append(("reprise", f"{data.get('title', '')} ({len(data['messages'])} messages)"))
print(ui.banner(rows))
try:
# Mode « une seule demande » : la demande est passée en argument.
if args.demande:
handle_request(session, " ".join(args.demande))
return
while True:
text = input(ui.prompt()).strip()
if text in ("/quit", "/exit"):
break
if text.startswith("/"):
print(ui.indent(execute_command(session, text)))
elif text:
handle_request(session, text)
except (KeyboardInterrupt, EOFError):
print("\n" + ui.style(" Au revoir.", ui.GREY))
if __name__ == "__main__":
main()Essayer
llamagent # Pour comparer avec l'affichage simple $env:NO_COLOR = "1"; llamagent # Windows (PowerShell) NO_COLOR=1 llamagent # macOS llamagent "Explique @calc.py" > sortie.txt # redirection : texte simple
Ce qu'on doit observer
La capture ci-dessus est la sortie réelle de llamagent (avec un faux modèle), enregistrée dans un pseudo-terminal de 100 colonnes puis convertie en image par un petit émulateur de terminal. Les teintes exactes dépendent du thème de ton terminal. Pendant que le modèle réfléchit :

Tests
"""Tests de l'interface (ui.py)."""
from collections import namedtuple
import pytest
from llamagent import ui
Size = namedtuple("Size", "columns lines")
def plain(text):
return ui.ANSI_CODE.sub("", text)
@pytest.fixture
def couleurs(monkeypatch):
"""Comme dans un vrai terminal : couleurs et caractères Unicode."""
monkeypatch.setattr(ui, "STYLE", True)
monkeypatch.setattr(ui, "UNICODE", True)
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(100, 40))
@pytest.fixture
def texte_simple(monkeypatch):
"""Comme une sortie redirigée vers un fichier : ni couleurs, ni Unicode."""
monkeypatch.setattr(ui, "STYLE", False)
monkeypatch.setattr(ui, "UNICODE", False)
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(100, 40))
def test_style_et_longueur_visible(couleurs):
texte = ui.style("bonjour", ui.BOLD, ui.TEAL)
assert texte == "\033[1;38;5;38mbonjour\033[0m"
assert ui.visible_len(texte) == 7
def test_mascotte(couleurs):
lignes = [plain(ligne) for ligne in ui.mascot()]
assert lignes == ui.MASCOT
assert ui.ORANGE in ui.mascot()[0] # l'antenne est orange
def test_banniere(couleurs):
banniere = plain(ui.banner([("modèle", "qwen3.5:4b"), ("dossier", "/home/nils/projets/calc")]))
assert "│o o│ un agent de code minimal autour d’Ollama" in banniere
assert "┌─┴─┐ llamagent" in banniere
assert " modèle qwen3.5:4b" in banniere
def test_banniere_raccourcit_les_chemins_longs(couleurs, monkeypatch):
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(40, 40))
banniere = plain(ui.banner([("dossier", "/home/nils/" + "x" * 100 + "/calc")]))
ligne = banniere.splitlines()[-1]
assert ligne.endswith("/calc") and "…" in ligne
def test_sans_couleurs_ni_unicode(texte_simple):
banniere = ui.banner([("modèle", "qwen3.5:4b")])
assert "\033" not in banniere
assert "|o o|" in banniere # la mascotte en ASCII
assert ui.render_markdown("**gras** et `code`") == "**gras** et `code`" # Markdown laissé tel quel
def test_les_acteurs_sont_alignes(couleurs):
lignes = [plain(ui.role_line(nom, "texte")) for nom in ("vous", "modèle", "harness", "outil")]
assert {ligne.index("texte") for ligne in lignes} == {len(ui.INDENT)}
def test_le_modele_demande(couleurs):
assert plain(ui.model_asks("edit_file", {"path": "calc.py", "old_text": "a", "new_text": "b"})) == " modèle demande edit_file(calc.py, …)"
assert plain(ui.model_asks("list_files", {})) == " modèle demande list_files()"
def test_resultat_de_l_outil(couleurs):
assert ui.RED in ui.tool_result("Refusé : chemin refusé")
assert ui.RED not in ui.tool_result("Fichier calc.py modifié.")
assert plain(ui.tool_result("a\nb\nc", truncated=True)) == " outil a (+2 lignes) [tronqué]"
def test_traits_de_separation(couleurs):
assert plain(ui.tour_header(2)).strip().startswith("── tour 2 ──")
assert "réponse · 3 appels au modèle" in plain(ui.answer_header(3))
assert "1 appel au modèle" in plain(ui.answer_header(1))
def test_markdown(couleurs):
lignes = ui.render_markdown("# Titre\n- **gras** et `code`\n```\nprint(1)\n```").splitlines()
assert plain(lignes[0]) == "Titre"
assert plain(lignes[1]) == "► gras et code"
assert plain(lignes[2]) == "│ print(1)" # les ``` ne sont pas affichés
assert len(lignes) == 3
def test_jauge_de_contexte(couleurs):
assert "█████░░░░░" in ui.context_gauge("qwen3.5:4b", 4096, 8192, False)
assert ui.YELLOW in ui.context_gauge("qwen3.5:4b", 7000, 8192, False)
plein = ui.context_gauge("qwen3.5:4b", 9000, 8192, False)
assert ui.RED in plein and "plein" in plein
assert "≈882 / 2048" in plain(ui.context_gauge("qwen3.5:4b", 882, 2048, True))
def test_confirmation_avec_diff_en_couleurs(couleurs, monkeypatch, capsys):
questions = []
monkeypatch.setattr("builtins.input", lambda question: questions.append(question) or "o")
assert ui.confirm("--- a.py (avant)\n+++ a.py (après)\n-x = 1\n+x = 2\nÉcrire a.py ?") is True
sortie = capsys.readouterr().out
assert ui.INDENT + ui.style("-x = 1", ui.RED) in sortie # aligné sous le texte du harness
assert ui.style("+x = 2", ui.GREEN) in sortie
assert "Écrire a.py ?" in questions[0] and "[o/N]" in questions[0]
def test_animation_sans_terminal(texte_simple):
with ui.thinking():
pass # ne fait rien, et surtout ne bloque pasPièges à surveiller
- Rencontré : ne pas copier. Une première version reprenait les codes visuels courants des agents en ligne de commande (grand logo en dégradé, puces, encadré). Elle a été remplacée par une interface propre au projet, qui sert le tutoriel : la boucle rendue visible.
- Rencontré en écrivant ui.py : Python 3.11 refuse les mêmes guillemets à l'intérieur d'une f-string (
f"{d["a"]}"). C'est autorisé seulement depuis Python 3.12 : le code doit tourner sur 3.11, la version minimale du projet. - Windows : dans les anciennes consoles, les codes ANSI ne sont actifs qu'après
os.system(""), appelé au démarrage. Windows Terminal les comprend directement. - Police : la mascotte et les traits utilisent des caractères de dessin (
┌ ┴ │ ─ █). Consolas, Cascadia Mono et Menlo les ont ; sinon, des carrés apparaissent : changer de police, ou utiliserNO_COLOR. - Le Terminal de macOS ne gère que 256 couleurs : d'où la palette
38;5;Nplutôt que les couleurs 24 bits (38;2;R;G;B). - L'animation écrit pendant que le programme attend : si un autre texte s'affichait au même moment, les lignes se mélangeraient. Elle n'entoure donc que l'appel au modèle, jamais une confirmation.
- Chaque tour ajoute plusieurs lignes : sur une longue tâche, l'écran défile vite. C'est voulu pour apprendre ; pour un usage quotidien, on pourrait n'afficher que le modèle et l'outil.
Objectif
Tester automatiquement llamagent sous Windows, macOS et Linux à chaque modification, et le publier sur PyPI pour que n'importe qui l'installe avec uv tool install llamagent.
Concept
La CI (intégration continue) relance les tests à chaque push, sur des machines neuves fournies par GitHub Actions. La CD (déploiement continu) publie une nouvelle version quand on pousse une étiquette comme v0.1.0. Les deux sont décrites dans des fichiers YAML, dans .github/workflows/.
- ci.yml : 9 jobs de tests (3 systèmes × Python 3.11, 3.12 et 3.13).
uv sync --lockedinstalle exactement les versions deuv.lock. Un job « Paquet » construit le paquet, vérifie son contenu, l'installe dans un dossier vierge et le lance (echo /quit | llamagent) : c'est là qu'on voit si les prompts et les skills sont bien dedans. - release.yml : tests, construction, vérification que l'étiquette correspond à la version de
pyproject.toml, publication sur TestPyPI (une copie de PyPI pour s'entraîner), puis sur PyPI après ta validation manuelle. - Trusted Publishing : aucun mot de passe ni jeton dans le dépôt. PyPI fait confiance à ce workflow, de ce dépôt, dans cet environnement GitHub ; GitHub lui prouve son identité avec un jeton de courte durée (OIDC), d'où
permissions: id-token: write. - Préparer le paquet :
prompts/etskills/passent danssrc/llamagent/pour être installés avec le code. L'adresse par défaut devient celle d'Ollama sur la machine (http://localhost:11434) : un Ollama distant se règle dans~/.llamagent/config.toml. La fiche du paquet (licence, liens, classifiers) est complétée, etllamagent --versionaffiche la version installée.
Le projet s'appelait mini-agent pendant son développement. PyPI ayant refusé ce nom (voir les pièges), tout a été renommé llamagent : le paquet sur PyPI, la commande, le module Python (src/llamagent/), le dossier .llamagent/ des projets et les variables LLAMAGENT_*. Le dépôt GitHub est devenu nils-malmberg/llamagent. Toute la page utilise désormais ce nom.
[project]
# Le projet s'appelait « mini-agent » ; PyPI a refusé ce nom, trop proche de
# « miniagent » (étape 18). Paquet, commande et module s'appellent llamagent.
name = "llamagent"
version = "0.1.0"
description = "Un agent de code minimal autour d'Ollama, écrit à la main pour comprendre chaque brique."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nils Malmberg" }]
requires-python = ">=3.11"
dependencies = [
"requests>=2.34.2",
]
keywords = ["agent", "coding-agent", "llm", "ollama", "tutoriel"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Education",
"Natural Language :: French",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development",
]
[project.urls]
Documentation = "https://nils-malmberg.fr/project8.html"
Repository = "https://github.com/nils-malmberg/llamagent"
Issues = "https://github.com/nils-malmberg/llamagent/issues"
[project.scripts]
llamagent = "llamagent.cli:main"
[build-system]
# 1.27 : première version qui comprend « license = "MIT" » (PEP 639).
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]# Valeurs par défaut : Ollama sur la machine elle-même (son adresse standard), # et un modèle 4B qui tient dans 6 Go de VRAM. Un Ollama installé ailleurs # (sur un NAS par exemple) se règle dans ~/.llamagent/config.toml. DEFAULT_OLLAMA_URL = "http://localhost:11434" DEFAULT_OLLAMA_MODEL = "qwen3.5:4b" # prompts/ et skills/ font partie du paquet (étape 18) : ils sont à côté de # ce fichier, dans src/llamagent/, et donc installés avec le code, que # llamagent vienne du dépôt ou de PyPI. PACKAGE_DIR = Path(__file__).resolve().parent PROMPTS_DIR = PACKAGE_DIR / "prompts" SKILLS_DIR = PACKAGE_DIR / "skills"
# Intégration continue (étape 18) : à chaque push sur main et à chaque pull
# request, les tests tournent sous Linux, Windows et macOS, avec Python 3.11
# à 3.13. Puis le paquet est construit, vérifié et installé comme le ferait
# un utilisateur.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
tests:
name: Tests (${{ matrix.os }}, Python ${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # voir tous les systèmes en échec, pas seulement le premier
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python }}
- name: Installer les dépendances (versions exactes de uv.lock)
run: uv sync --locked
- name: Tests
run: uv run pytest -q
package:
name: Paquet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- name: Construire
run: uv build
- name: Vérifier le contenu
run: python3 scripts/check_package.py
- name: Installer le paquet construit et le lancer
run: |
uv tool install dist/*.whl
LLAMAGENT="$(uv tool dir --bin)/llamagent"
"$LLAMAGENT" --version
mkdir "$RUNNER_TEMP/projet" && cd "$RUNNER_TEMP/projet"
echo /quit | "$LLAMAGENT"# Publication (étape 18) : quand une étiquette de version (v0.1.0...) est
# poussée, le paquet est testé, construit et vérifié, publié sur TestPyPI,
# puis sur PyPI après validation manuelle (environnement « pypi » protégé).
# Aucun mot de passe ni jeton : PyPI fait confiance à ce workflow précis
# (Trusted Publishing), qui prouve son identité avec un jeton OIDC de GitHub.
name: Publication
on:
push:
tags: ["v*"]
jobs:
build:
name: Tester et construire
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- name: Tests
run: |
uv sync --locked
uv run pytest -q
- name: Construire
run: uv build
- name: Vérifier le paquet et la version
run: python3 scripts/check_package.py "$GITHUB_REF_NAME"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
testpypi:
name: Publier sur TestPyPI
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/llamagent
permissions:
id-token: write # nécessaire au Trusted Publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
pypi:
name: Publier sur PyPI
needs: testpypi
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/llamagent
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1"""Vérifie le paquet construit avant de le publier (étape 18).
python scripts/check_package.py # le contenu du paquet (dist/)
python scripts/check_package.py v0.1.0 # et l'étiquette correspond à la version
"""
import re
import sys
import tomllib
import zipfile
from pathlib import Path
# Fichiers sans lesquels llamagent ne démarre pas : s'ils manquent dans le
# paquet, il s'installe sans erreur mais plante au premier lancement.
REQUIRED = [
"llamagent/cli.py",
"llamagent/prompts/AGENT.md",
"llamagent/prompts/COMPACT.md",
"llamagent/skills/corriger-un-bug.md",
"llamagent/skills/ecrire-tests-pytest.md",
]
def main() -> None:
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
version = project["version"]
# Dans le nom du fichier .whl, le nom du paquet est normalisé : « - » devient « _ ».
name = re.sub(r"[-_.]+", "_", project["name"]).lower()
if len(sys.argv) > 1 and sys.argv[1] != f"v{version}":
sys.exit(f"L'étiquette {sys.argv[1]} ne correspond pas à la version v{version} de pyproject.toml.")
wheels = list(Path("dist").glob(f"{name}-{version}-*.whl"))
if len(wheels) != 1:
sys.exit(f"Un seul paquet {name}-{version} attendu dans dist/, trouvé : {[w.name for w in wheels]}")
names = zipfile.ZipFile(wheels[0]).namelist()
missing = [name for name in REQUIRED if name not in names]
if missing:
sys.exit(f"Fichiers absents du paquet : {', '.join(missing)}")
print(f"{wheels[0].name} : {len(names)} fichiers, prompts et skills présents.")
if __name__ == "__main__":
main()Un bug trouvé en préparant la CI
Avec shell=True, le timeout de run_command n'arrêtait que le shell, pas les programmes lancés par la commande. Sous Linux et macOS, ils continuaient de tourner en arrière-plan ; sous Windows, subprocess.run attendait même leur fin, et le timeout ne protégeait de rien. Un test le reproduit (un programme qui en lance un autre), puis la correction arrête toute l'arborescence de processus :
def kill_process_tree(process: subprocess.Popen) -> None:
"""Arrête une commande et tous les programmes qu'elle a lancés (étape 18).
process.kill() n'arrêterait que le shell : les programmes lancés par la
commande continueraient de tourner, et sous Windows on attendrait leur fin.
"""
if os.name == "nt":
# /T : toute l'arborescence de processus, /F : sans attendre.
subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True)
else:
# La commande a son propre groupe de processus (start_new_session) :
# on envoie le signal à tout le groupe.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass # tout s'est terminé entre-temps
def run_command(ctx: ToolContext, command: str) -> str:
"""Lance une commande shell dans le dossier de travail, après confirmation."""
# Les commandes de la liste autorisée passent sans question (étape 14).
if not is_allowed_command(command, ctx.allowed_commands) and not ctx.confirm(f"Exécuter : {command}"):
return "Refusé par l'utilisateur : la commande n'a pas été exécutée."
# Force Python (s'il est lancé par la commande) à écrire en UTF-8,
# sinon Windows utilise cp1252 et les accents deviennent illisibles.
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
process = subprocess.Popen(
command,
shell=True, # cmd.exe sous Windows, /bin/sh sous macOS
cwd=ctx.workdir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL, # une commande ne peut pas attendre une saisie au clavier
encoding="utf-8",
errors="replace",
env=env,
start_new_session=os.name != "nt", # son propre groupe de processus (Linux, macOS)
)
try:
stdout, stderr = process.communicate(timeout=ctx.command_timeout)
except subprocess.TimeoutExpired:
kill_process_tree(process)
process.communicate() # récupère ce qui reste, maintenant que tout est arrêté
return f"Erreur : la commande a dépassé {ctx.command_timeout} s et a été arrêtée."
return (
f"code de sortie : {process.returncode}\n"
f"--- stdout ---\n{stdout}\n"
f"--- stderr ---\n{stderr}"
)def test_run_command_timeout_arrete_aussi_les_sous_processus(ctx):
# parent.py lance enfant.py, qui écrit fini.txt au bout de 3 s. Tuer
# seulement le shell ne suffit pas : l'enfant survivrait (et, sous
# Windows, on attendrait sa fin car il garde la sortie ouverte).
(ctx.workdir / "enfant.py").write_text(
"import time, pathlib\ntime.sleep(3)\npathlib.Path('fini.txt').write_text('x')\n", encoding="utf-8"
)
(ctx.workdir / "parent.py").write_text(
"import subprocess, sys\nsubprocess.run([sys.executable, 'enfant.py'])\n", encoding="utf-8"
)
ctx.command_timeout = 1
debut = time.monotonic()
resultat = execute_tool("run_command", {"command": "python parent.py"}, ctx)
assert "dépassé 1 s" in resultat
assert time.monotonic() - debut < 3 # pas d'attente de l'enfant
time.sleep(4)
assert not (ctx.workdir / "fini.txt").exists() # l'enfant a bien été arrêtéMettre en place (une seule fois)
- Sur pypi.org : Your account → Publishing → Add a new pending publisher (GitHub), avec :
PyPI Project Name : llamagent Owner : nils-malmberg Repository name : llamagent Workflow name : release.yml Environment name : pypi (testpypi sur test.pypi.org)
- La même chose sur test.pypi.org, avec l'environnement
testpypi. - Dans le dépôt GitHub : Settings → Environments, créer
testpypi, puispypiavec Required reviewers (toi) et, dans Deployment branches and tags, seulement les étiquettesv*. - Pousser
.github/workflows/,scripts/check_package.pyet les autres changements surmain: la CI démarre (onglet Actions).
Publier une version
# 1. Choisir la version dans pyproject.toml (ex. version = "0.1.0"), puis : uv lock git commit -am "Version 0.1.0" git push # 2. Quand la CI est verte sur main : git tag v0.1.0 git push origin v0.1.0 # 3. GitHub → Actions → Publication : TestPyPI se fait seul, # puis valider le déploiement « pypi » (Review deployments → Approve) # 4. Installer depuis PyPI, comme n'importe qui : uv tool install llamagent
Ce qu'on doit observer
Les workflows ne tournent que sur GitHub. Ici, leurs commandes ont été rejouées à l'identique : construction, vérification du paquet (et refus d'une étiquette qui ne correspond pas), installation du paquet construit dans un dossier vierge, puis lancement. Sortie réelle :
$ uv build
Successfully built dist/llamagent-0.1.0.tar.gz
Successfully built dist/llamagent-0.1.0-py3-none-any.whl
$ python3 scripts/check_package.py v0.1.0
llamagent-0.1.0-py3-none-any.whl : 21 fichiers, prompts et skills présents.
$ python3 scripts/check_package.py v0.2.0
L'étiquette v0.2.0 ne correspond pas à la version v0.1.0 de pyproject.toml.
$ uv tool install dist/llamagent-0.1.0-py3-none-any.whl
Installed 1 executable: llamagent
$ llamagent --version
llamagent 0.1.0
$ cd projet-vierge && echo /quit | llamagent
●
┌─┴─┐ llamagent
│o o│ un agent de code minimal autour d’Ollama
│ ▬ │
└┬─┬┘ /help commandes · @fichier joindre un fichier · /quit quitter
modèle qwen3.5:4b
serveur http://localhost:11434
dossier /home/user/projets/vierge
projet 2 skills · /init pour préparer le projet
vousSur GitHub, l'onglet Actions montre les 9 jobs de tests et le job « Paquet » ; lors d'une publication, le job « Publier sur PyPI » attend ta validation. La page du projet apparaît ensuite sur pypi.org/project/llamagent, avec le README comme description.
Publié pour de vrai. Ce n'est pas une simulation : la version 0.1.0 de llamagent est sur PyPI, publiée par ce workflow (roue et archive source, licence MIT, liens vers ce tutoriel et vers le dépôt GitHub). uv tool install llamagent l'installe sur n'importe quelle machine.
Tests
"""Tests de la ligne de commande (cli.py)."""
from importlib.metadata import version
import pytest
from llamagent.cli import PACKAGE_NAME, parse_args
def test_version(capsys):
with pytest.raises(SystemExit):
parse_args(["--version"])
assert capsys.readouterr().out.strip() == f"llamagent {version(PACKAGE_NAME)}"
def test_options():
args = parse_args(["-m", "qwen3.5:9b", "-t", "0.5", "-c", "Corrige", "le", "bug"])
assert (args.model, args.temperature, args.resume) == ("qwen3.5:9b", 0.5, True)
assert args.demande == ["Corrige", "le", "bug"]
def test_prompts_et_skills_dans_le_paquet():
from llamagent.config import PACKAGE_DIR, PROMPTS_DIR, SKILLS_DIR
assert PROMPTS_DIR.parent == SKILLS_DIR.parent == PACKAGE_DIR # installés avec le code
assert (PROMPTS_DIR / "AGENT.md").is_file() and (PROMPTS_DIR / "COMPACT.md").is_file()Pièges à surveiller
- Rencontré : nom refusé. PyPI a refusé
mini-agent(« trop similaire à un projet existant ») alors qu'aucun projet ne porte ce nom exact. PyPI compare des noms « ultra-normalisés » : minuscules, sans-,_ni., aveco=0eti=l=1.mini-agentet le projet existantminiagentdonnent la même forme. Vérifier un nom, c'est appliquer cette règle à la liste complète des projets (https://pypi.org/simple/), pas chercher le nom exact. - Rencontré : renommer le paquet sans renommer le dossier du code. Avec
name = "llamagent"mais le code encore danssrc/mini_agent/,uv syncéchoue : hatchling cherche un dossier du même nom que le projet (src/llamagent/) et répond « Unable to determine which files to ship inside the wheel ». Renommer le dossier (et les imports), ou déclarer[tool.hatch.build.targets.wheel] packages = [...]. - Rencontré : l'installation éditable (
uv tool install -e) cachait un vrai problème :prompts/etskills/n'étaient pas dans le paquet. Seul un test sur le paquet construit, installé ailleurs, le révèle : d'où le job « Paquet » etcheck_package.py. - Rencontré : le timeout de
run_commandet les sous-processus, ci-dessus. La branche Windows dekill_process_tree(taskkill) n'a encore tourné que dans la CI : c'est le rôle de la matrice Windows. - Une version publiée sur PyPI ne peut jamais être republiée, même après suppression : changer le numéro à chaque publication. C'est pour ça qu'on passe d'abord par TestPyPI.
- Une publication « en attente » ne réserve pas le nom : si quelqu'un publie
llamagentavant toi, elle devient invalide. Publier la première version sans trop tarder. uv sync --lockedéchoue siuv.lockn'est plus à jour : relanceruv lockaprès chaque modification depyproject.toml.- Le premier passage sous Windows et macOS peut révéler d'autres différences (chemins, encodage, commandes) : c'est exactement à ça que sert la CI. Corriger, repousser, jusqu'au vert.
- Ton Ollama est sur le NAS : depuis cette étape, l'adresse par défaut est
localhost. Mettreurl = "http://192.168.1.76:11434"dans~/.llamagent/config.toml.
Code complet de la version finale
Chaque fichier de la version finale (après l'étape 18), en entier. Les scripts des étapes 1 à 4 sont donnés en entier dans leur étape.
[project]
# Le projet s'appelait « mini-agent » ; PyPI a refusé ce nom, trop proche de
# « miniagent » (étape 18). Paquet, commande et module s'appellent llamagent.
name = "llamagent"
version = "0.1.0"
description = "Un agent de code minimal autour d'Ollama, écrit à la main pour comprendre chaque brique."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nils Malmberg" }]
requires-python = ">=3.11"
dependencies = [
"requests>=2.34.2",
]
keywords = ["agent", "coding-agent", "llm", "ollama", "tutoriel"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Education",
"Natural Language :: French",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development",
]
[project.urls]
Documentation = "https://nils-malmberg.fr/project8.html"
Repository = "https://github.com/nils-malmberg/llamagent"
Issues = "https://github.com/nils-malmberg/llamagent/issues"
[project.scripts]
llamagent = "llamagent.cli:main"
[build-system]
# 1.27 : première version qui comprend « license = "MIT" » (PEP 639).
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]3.11
MIT License Copyright (c) 2026 Nils Malmberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Configuration de llamagent.
Les réglages viennent de plusieurs sources. Chacune remplace la précédente :
1. les valeurs par défaut ci-dessous ;
2. le fichier global ~/.llamagent/config.toml ;
3. le fichier du projet .llamagent/config.toml (dans le dossier de travail) ;
4. les variables d'environnement OLLAMA_URL et OLLAMA_MODEL ;
5. les options de la ligne de commande (--model, --temperature...).
Les limites des garde-fous, elles, sont fixées ici.
"""
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
# Valeurs par défaut : Ollama sur la machine elle-même (son adresse standard),
# et un modèle 4B qui tient dans 6 Go de VRAM. Un Ollama installé ailleurs
# (sur un NAS par exemple) se règle dans ~/.llamagent/config.toml.
DEFAULT_OLLAMA_URL = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
# Température basse : on veut un agent prévisible, pas créatif.
TEMPERATURE = 0.2
# Taille de la fenêtre de contexte (en tokens) demandée à Ollama.
# 8192 est un bon compromis pour un modèle 4B sur une carte de 6 Go.
NUM_CTX = 8192
# Délai maximal d'une requête à Ollama, en secondes. Le premier appel est
# lent car Ollama doit charger le modèle en VRAM.
REQUEST_TIMEOUT = 120
# Garde-fous (étape 6).
MAX_TOURS = 15 # nombre maximal d'appels au LLM pour une même demande
COMMAND_TIMEOUT = 30 # durée maximale d'une commande lancée par run_command (s)
MAX_OUTPUT_CHARS = 4000 # taille maximale d'un résultat d'outil (~1000 tokens)
# Compactage automatique quand le contexte est rempli à 80 % (étape 16).
COMPACT_THRESHOLD = 0.8
# prompts/ et skills/ font partie du paquet (étape 18) : ils sont à côté de
# ce fichier, dans src/llamagent/, et donc installés avec le code, que
# llamagent vienne du dépôt ou de PyPI.
PACKAGE_DIR = Path(__file__).resolve().parent
PROMPTS_DIR = PACKAGE_DIR / "prompts"
SKILLS_DIR = PACKAGE_DIR / "skills"
# Réglages propres à chaque projet (étape 9) : un dossier .llamagent/ dans
# le dossier de travail, et un fichier AGENTS.md à sa racine.
PROJECT_DIR_NAME = ".llamagent"
PROJECT_INSTRUCTIONS = "AGENTS.md"
# Fichier de réglages global de l'utilisateur (étape 12).
USER_CONFIG = Path.home() / PROJECT_DIR_NAME / "config.toml"
# Les seules clés acceptées dans un fichier config.toml.
SETTINGS_KEYS = {"url", "model", "temperature", "num_ctx", "allowed_commands"}
@dataclass(frozen=True)
class Config:
"""Réglages de la session. Pour en changer un : dataclasses.replace()."""
ollama_url: str
model: str
workdir: Path # le seul dossier où l'agent a le droit de lire et d'écrire
temperature: float = TEMPERATURE
num_ctx: int = NUM_CTX
# Commandes que run_command lance sans demander confirmation (étape 14).
allowed_commands: tuple[str, ...] = ()
@property
def chat_url(self) -> str:
"""URL de l'API native d'Ollama (et non la couche /v1 compatible OpenAI)."""
return f"{self.ollama_url}/api/chat"
def options(self) -> dict:
"""Bloc `options` envoyé à Ollama à chaque appel."""
return {"temperature": self.temperature, "num_ctx": self.num_ctx}
def read_toml(path: Path) -> dict:
"""Lit un fichier de réglages TOML. Renvoie {} s'il n'existe pas."""
if not path.is_file():
return {}
try:
settings = tomllib.loads(path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as error:
raise SystemExit(f"Fichier de réglages invalide : {path}\n{error}")
# Une faute de frappe (« temperatur ») serait sinon ignorée sans prévenir.
unknown = set(settings) - SETTINGS_KEYS
if unknown:
raise SystemExit(f"Réglage inconnu dans {path} : {', '.join(sorted(unknown))}")
return settings
def load_config(cli_options: dict | None = None) -> Config:
"""Construit la configuration à partir de toutes les sources, dans l'ordre."""
# Par défaut, l'agent travaille dans le dossier où on le lance.
workdir = Path(os.environ.get("LLAMAGENT_WORKDIR", Path.cwd())).resolve()
# Un dossier de travail absent ferait échouer tous les outils plus tard,
# avec des erreurs obscures (WinError 267 sous Windows) : on s'arrête net.
if not workdir.is_dir():
raise SystemExit(f"Dossier de travail introuvable : {workdir} (vérifie LLAMAGENT_WORKDIR).")
settings = {"url": DEFAULT_OLLAMA_URL, "model": DEFAULT_OLLAMA_MODEL, "temperature": TEMPERATURE, "num_ctx": NUM_CTX}
settings.update(read_toml(USER_CONFIG))
settings.update(read_toml(workdir / PROJECT_DIR_NAME / "config.toml"))
if "OLLAMA_URL" in os.environ:
settings["url"] = os.environ["OLLAMA_URL"]
if "OLLAMA_MODEL" in os.environ:
settings["model"] = os.environ["OLLAMA_MODEL"]
# Options de la ligne de commande : seulement celles qui ont été données.
settings.update({key: value for key, value in (cli_options or {}).items() if value is not None})
try:
return Config(
# On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
ollama_url=str(settings["url"]).rstrip("/"),
model=str(settings["model"]),
workdir=workdir,
temperature=float(settings["temperature"]),
num_ctx=int(settings["num_ctx"]),
allowed_commands=tuple(str(command) for command in settings.get("allowed_commands", ())),
)
except (TypeError, ValueError) as error: # ex. temperature = "chaud"
raise SystemExit(f"Réglage invalide : {error}")"""Appel au LLM via l'API native d'Ollama.
C'est le seul module qui parle au réseau. Le reste du projet ne manipule
que des listes de messages (des dictionnaires Python).
"""
import requests
from llamagent.config import REQUEST_TIMEOUT, Config
def chat_full(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation à /api/chat et renvoie la réponse JSON complète.
En plus du message, la réponse contient des compteurs utiles, comme
prompt_eval_count (tokens lus) et eval_count (tokens générés).
"""
payload = {
"model": config.model,
"messages": messages,
"stream": False,
"think": False,
"options": config.options(),
}
if tools:
payload["tools"] = tools
response = requests.post(config.chat_url, json=payload, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.json()
def chat(config: Config, messages: list[dict], tools: list[dict] | None = None) -> dict:
"""Envoie la conversation (et la liste des outils) à Ollama.
Renvoie le message de l'assistant, par exemple :
{"role": "assistant", "content": "Bonjour !"}
ou, si le modèle veut utiliser un outil :
{"role": "assistant", "content": "",
"tool_calls": [{"function": {"name": "read_file",
"arguments": {"path": "main.py"}}}]}
"""
return chat_full(config, messages, tools)["message"]
def list_models(config: Config) -> list[str]:
"""Noms des modèles installés sur le serveur Ollama (GET /api/tags)."""
response = requests.get(f"{config.ollama_url}/api/tags", timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return sorted(model["name"] for model in response.json()["models"])"""Garde-fous : ce qui protège l'utilisateur contre les erreurs du modèle.
Le modèle peut se tromper de chemin, vouloir écrire n'importe où ou lancer
une commande qui ne s'arrête jamais. Ces vérifications sont faites par le
harness, en Python : on ne compte pas sur le modèle pour les respecter.
"""
from pathlib import Path
class GuardError(Exception):
"""Action refusée par un garde-fou. Le message est renvoyé au modèle."""
def safe_path(workdir: Path, path: str) -> Path:
"""Transforme un chemin donné par le modèle en chemin sûr.
Refuse tout chemin qui contient ".." ou qui sort du dossier de travail
(par exemple un chemin absolu comme /etc/passwd ou C:\\Windows).
"""
if ".." in Path(path).parts:
raise GuardError(f"chemin refusé ({path}) : '..' est interdit.")
root = workdir.resolve()
# Si `path` est absolu, `root / path` vaut `path` : d'où la vérification.
full = (root / path).resolve()
if not full.is_relative_to(root):
raise GuardError(f"chemin refusé ({path}) : il sort du dossier de travail {root}.")
return full
def ask_confirmation(question: str) -> bool:
"""Demande une confirmation à l'utilisateur. Par défaut (Entrée) : non."""
answer = input(f"{question} [o/N] ").strip().lower()
return answer in ("o", "oui")
def truncate(text: str, max_chars: int) -> str:
"""Raccourcit un texte trop long en gardant le début et la fin.
La fin est souvent la partie utile (message d'erreur, traceback).
"""
if len(text) <= max_chars:
return text
half = max_chars // 2
removed = len(text) - 2 * half
return f"{text[:half]}\n[... {removed} caractères tronqués ...]\n{text[-half:]}"
# Caractères qui permettent d'enchaîner, de rediriger ou de substituer des
# commandes (%VAR% est une variable sous Windows, $(...) une sous-commande).
SHELL_OPERATORS = ("&", "|", ";", ">", "<", "`", "$(", "\n", "%")
def is_allowed_command(command: str, allowed: list[str]) -> bool:
"""Vrai si la commande peut être lancée sans demander confirmation.
Elle doit commencer par une commande de la liste (« python -m pytest »
autorise « python -m pytest -q ») et ne contenir aucun opérateur shell :
sinon « git status && del *.py » passerait pour « git status ».
C'est un confort, pas une barrière de sécurité : la liste doit rester
limitée à des commandes sans danger.
"""
command = command.strip()
if any(operator in command for operator in SHELL_OPERATORS):
return False
return any(command == prefix or command.startswith(prefix + " ") for prefix in allowed)"""Les outils mis à disposition du modèle, et leur registre.
Un outil = une fonction Python + un schéma JSON qui la décrit au modèle.
Le modèle ne voit que le schéma (nom, description, paramètres) ; c'est le
harness qui exécute la fonction quand le modèle la demande.
Pour ajouter un outil : écrire la fonction, écrire son schéma, ajouter une
ligne dans TOOLS. La boucle agentique (agent.py) n'a pas à être modifiée.
"""
import difflib
import json
import os
import signal
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from llamagent.config import COMMAND_TIMEOUT
from llamagent.guards import GuardError, is_allowed_command, safe_path
# Dossiers jamais listés : sans intérêt pour le modèle et souvent énormes.
IGNORED_DIRS = {"__pycache__", "node_modules"}
MAX_LISTED_FILES = 200
@dataclass
class ToolContext:
"""Ce dont les outils ont besoin pour travailler, fourni par le harness."""
workdir: Path # dossier de travail : les outils ne sortent pas d'ici
skills_dirs: list[Path] # dossiers des skills, du plus général au plus précis
confirm: Callable[[str], bool] # pose une question [o/N] à l'utilisateur
command_timeout: int = COMMAND_TIMEOUT
# Commandes lancées sans confirmation (étape 14), modifiable avec /allow.
allowed_commands: list[str] = field(default_factory=list)
# ─────────────────────────── Les outils ───────────────────────────
def read_file(ctx: ToolContext, path: str) -> str:
"""Renvoie le contenu d'un fichier texte du dossier de travail."""
return safe_path(ctx.workdir, path).read_text(encoding="utf-8")
def list_files(ctx: ToolContext, path: str = ".") -> str:
"""Liste les fichiers d'un dossier (et de ses sous-dossiers)."""
root = safe_path(ctx.workdir, path)
# Sans cette vérification, os.walk ignore en silence un dossier absent :
# on répondrait « aucun fichier » et le modèle croirait le dossier vide.
if not root.is_dir():
return f"Erreur : '{path}' n'est pas un dossier existant. Utilise '.' pour le dossier de travail."
workdir = ctx.workdir.resolve()
files = []
for dirpath, dirnames, filenames in os.walk(root):
# On modifie `dirnames` sur place pour qu'os.walk n'entre pas dans
# les dossiers cachés (.git, .venv...) ni dans les dossiers ignorés.
dirnames[:] = sorted(
d for d in dirnames if not d.startswith(".") and d not in IGNORED_DIRS
)
for name in sorted(filenames):
# as_posix() : toujours des "/" même sous Windows, pour le modèle.
files.append((Path(dirpath) / name).relative_to(workdir).as_posix())
if not files:
return "(aucun fichier)"
if len(files) > MAX_LISTED_FILES:
hidden = len(files) - MAX_LISTED_FILES
files = files[:MAX_LISTED_FILES] + [f"... et {hidden} autres fichiers"]
return "\n".join(files)
def edit_file(ctx: ToolContext, path: str, old_text: str, new_text: str) -> str:
"""Remplace un passage exact d'un fichier, ou crée un fichier si old_text est vide.
Le modèle n'envoie que le passage modifié, pas tout le fichier : c'est
beaucoup moins de contexte consommé, et le reste du fichier est intact.
"""
target = safe_path(ctx.workdir, path)
if old_text == "":
if target.exists():
return f"Erreur : {path} existe déjà. Pour le modifier, donne dans old_text le passage exact à remplacer."
old, content = "", new_text
else:
if not target.is_file():
return f"Erreur : fichier introuvable : {path}. Pour créer un fichier, laisse old_text vide."
old = target.read_text(encoding="utf-8")
count = old.count(old_text)
if count == 0:
return f"Erreur : old_text introuvable dans {path}. Relis le fichier avec read_file et copie le passage exactement."
if count > 1:
return f"Erreur : old_text apparaît {count} fois dans {path}. Ajoute des lignes autour pour qu'il soit unique."
content = old.replace(old_text, new_text, 1)
# On montre à l'utilisateur exactement ce qui va changer.
diff = "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile=f"{path} (avant)",
tofile=f"{path} (après)",
)
)
if not ctx.confirm(f"{diff or '(aucun changement)'}\nÉcrire {path} ?"):
return "Refusé par l'utilisateur : le fichier n'a pas été modifié."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Fichier {path} {'créé' if old_text == '' else 'modifié'}."
def kill_process_tree(process: subprocess.Popen) -> None:
"""Arrête une commande et tous les programmes qu'elle a lancés (étape 18).
process.kill() n'arrêterait que le shell : les programmes lancés par la
commande continueraient de tourner, et sous Windows on attendrait leur fin.
"""
if os.name == "nt":
# /T : toute l'arborescence de processus, /F : sans attendre.
subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True)
else:
# La commande a son propre groupe de processus (start_new_session) :
# on envoie le signal à tout le groupe.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass # tout s'est terminé entre-temps
def run_command(ctx: ToolContext, command: str) -> str:
"""Lance une commande shell dans le dossier de travail, après confirmation."""
# Les commandes de la liste autorisée passent sans question (étape 14).
if not is_allowed_command(command, ctx.allowed_commands) and not ctx.confirm(f"Exécuter : {command}"):
return "Refusé par l'utilisateur : la commande n'a pas été exécutée."
# Force Python (s'il est lancé par la commande) à écrire en UTF-8,
# sinon Windows utilise cp1252 et les accents deviennent illisibles.
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
process = subprocess.Popen(
command,
shell=True, # cmd.exe sous Windows, /bin/sh sous macOS
cwd=ctx.workdir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL, # une commande ne peut pas attendre une saisie au clavier
encoding="utf-8",
errors="replace",
env=env,
start_new_session=os.name != "nt", # son propre groupe de processus (Linux, macOS)
)
try:
stdout, stderr = process.communicate(timeout=ctx.command_timeout)
except subprocess.TimeoutExpired:
kill_process_tree(process)
process.communicate() # récupère ce qui reste, maintenant que tout est arrêté
return f"Erreur : la commande a dépassé {ctx.command_timeout} s et a été arrêtée."
return (
f"code de sortie : {process.returncode}\n"
f"--- stdout ---\n{stdout}\n"
f"--- stderr ---\n{stderr}"
)
def load_skill(ctx: ToolContext, name: str) -> str:
"""Renvoie le contenu d'une skill (fichier Markdown d'un dossier de skills)."""
skills = find_skills(ctx.skills_dirs)
# On vérifie le nom dans la liste : impossible de lire un autre fichier.
if name not in skills:
raise GuardError(f"skill inconnue '{name}'. Skills disponibles : {', '.join(skills) or 'aucune'}.")
return skills[name].read_text(encoding="utf-8")
# ─────────────────────────── Skills ───────────────────────────
def read_skill_description(path: Path) -> str:
"""Lit la ligne `description:` de l'en-tête d'une skill.
Une skill commence par un petit en-tête entre deux lignes "---" :
---
description: Écrire des tests pytest pour du code Python.
---
"""
lines = path.read_text(encoding="utf-8").splitlines()
if lines and lines[0].strip() == "---":
for line in lines[1:]:
if line.strip() == "---":
break
key, _, value = line.partition(":")
if key.strip() == "description":
return value.strip()
return "(pas de description)"
def find_skills(skills_dirs: list[Path]) -> dict[str, Path]:
"""Renvoie {nom de la skill: fichier} pour les fichiers .md de chaque dossier.
Les dossiers sont lus dans l'ordre : une skill du projet (dernier dossier)
remplace une skill globale du même nom.
"""
skills = {}
for skills_dir in skills_dirs:
if skills_dir.is_dir():
skills.update({p.stem: p for p in sorted(skills_dir.glob("*.md"))})
return skills
def available_skills(skills_dirs: list[Path]) -> dict[str, str]:
"""Renvoie {nom de la skill: description}, pour le prompt système."""
return {name: read_skill_description(path) for name, path in find_skills(skills_dirs).items()}
# ─────────────────────────── Le registre ───────────────────────────
def schema(name: str, description: str, properties: dict, required: list[str]) -> dict:
"""Construit le schéma d'un outil au format attendu par Ollama."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {"type": "object", "properties": properties, "required": required},
},
}
PATH_PARAM = {"type": "string", "description": "Chemin relatif au dossier de travail, ex. 'src/main.py'."}
# nom de l'outil -> (fonction Python, schéma envoyé au modèle)
TOOLS: dict[str, tuple[Callable[..., str], dict]] = {
"read_file": (
read_file,
schema("read_file", "Lit un fichier texte et renvoie tout son contenu.", {"path": PATH_PARAM}, ["path"]),
),
"list_files": (
list_files,
schema(
"list_files",
"Liste les fichiers d'un dossier et de ses sous-dossiers. Utilise '.' pour le dossier de travail.",
{"path": PATH_PARAM},
[],
),
),
"edit_file": (
edit_file,
schema(
"edit_file",
"Modifie un fichier : remplace old_text (copié exactement depuis read_file, présent une seule fois) par new_text. "
"Pour créer un nouveau fichier, laisse old_text vide.",
{
"path": PATH_PARAM,
"old_text": {"type": "string", "description": "Passage exact à remplacer, ou '' pour créer le fichier."},
"new_text": {"type": "string", "description": "Nouveau texte."},
},
["path", "old_text", "new_text"],
),
),
"run_command": (
run_command,
schema(
"run_command",
"Exécute une commande shell dans le dossier de travail et renvoie sa sortie. Ex. 'python script.py'.",
{"command": {"type": "string", "description": "La commande à exécuter."}},
["command"],
),
),
"load_skill": (
load_skill,
schema(
"load_skill",
"Charge les instructions d'une skill listée dans le prompt système.",
{"name": {"type": "string", "description": "Nom exact de la skill, ex. 'corriger-un-bug'."}},
["name"],
),
),
}
def tool_schemas() -> list[dict]:
"""Les schémas de tous les outils, à envoyer au modèle."""
return [tool_schema for _, tool_schema in TOOLS.values()]
def execute_tool(name: str, arguments, ctx: ToolContext) -> str:
"""Exécute un outil demandé par le modèle et renvoie toujours un texte.
Aucune erreur ne doit faire planter l'agent : chaque problème est
décrit dans le texte renvoyé, pour que le modèle puisse se corriger.
"""
if name not in TOOLS:
return f"Erreur : outil inconnu '{name}'. Outils valides : {', '.join(TOOLS)}."
function, tool_schema = TOOLS[name]
# Certains modèles renvoient les arguments sous forme de texte JSON.
if isinstance(arguments, str):
try:
arguments = json.loads(arguments or "{}")
except json.JSONDecodeError:
return f"Erreur : les arguments de {name} ne sont pas du JSON valide : {arguments}"
if not isinstance(arguments, dict):
return f"Erreur : les arguments de {name} doivent être un objet JSON."
parameters = tool_schema["function"]["parameters"]
expected = ", ".join(parameters["properties"])
missing = [p for p in parameters["required"] if p not in arguments]
if missing:
return f"Erreur : argument manquant pour {name} : {', '.join(missing)}. Paramètres attendus : {expected}."
unknown = [p for p in arguments if p not in parameters["properties"]]
if unknown:
return f"Erreur : argument inconnu pour {name} : {', '.join(unknown)}. Paramètres attendus : {expected}."
try:
return function(ctx, **arguments)
except GuardError as error:
return f"Refusé : {error}"
except Exception as error: # toute autre exception Python
return f"Erreur pendant {name} : {type(error).__name__}: {error}""""La boucle agentique : le cœur de llamagent.
tant que le modèle demande des outils :
exécuter chaque outil demandé
ajouter le résultat à la conversation
rappeler le modèle
sinon : renvoyer sa réponse finale
La boucle ne connaît aucun outil en particulier : elle passe par le registre
de tools.py. Elle ne parle pas non plus directement à Ollama : on lui donne
une fonction `chat`, ce qui permet de la tester avec un faux modèle.
"""
import json
import os
import platform
from pathlib import Path
from typing import Callable
from llamagent import ui
from llamagent.config import MAX_OUTPUT_CHARS, MAX_TOURS
from llamagent.guards import is_allowed_command, truncate
from llamagent.tools import TOOLS, ToolContext, execute_tool, tool_schemas
# Une fonction qui reçoit (messages, schémas des outils) et renvoie le message
# de l'assistant. En vrai : llm.chat. Dans les tests : un faux modèle.
ChatFunction = Callable[[list[dict], list[dict]], dict]
# ─────────────────────────── Prompt système ───────────────────────────
def build_system_prompt(template: str, workdir: Path, skills: dict[str, str], project_instructions: str = "") -> str:
"""Remplit le modèle de prompt (prompts/AGENT.md) avec l'environnement réel.
Si le projet a un fichier AGENTS.md (étape 9), son contenu est ajouté à la fin.
"""
shell = "cmd.exe" if os.name == "nt" else "/bin/sh"
skills_text = "\n".join(f"- {name} : {description}" for name, description in skills.items())
# str.replace plutôt que str.format : le Markdown peut contenir des { }.
prompt = (
template.replace("{os}", platform.system())
.replace("{shell}", shell)
.replace("{workdir}", str(workdir))
.replace("{skills}", skills_text or "(aucune)")
)
if project_instructions:
prompt += f"\n\n## Instructions du projet (AGENTS.md)\n{project_instructions}"
return prompt
# ─────────────────────────── La boucle ───────────────────────────
def describe_harness(name: str, arguments, ctx: ToolContext, repeated: bool) -> str:
"""Ce que le harness fait de la demande du modèle, en une phrase (étape 17).
Seulement pour l'affichage : on rend visible le rôle du harness entre
le modèle, qui demande, et l'outil, qui exécute.
"""
if repeated:
return "appel identique au précédent : pas exécuté, le modèle est prévenu"
if name not in TOOLS:
return "outil inconnu : l'erreur est renvoyée au modèle"
if name == "edit_file":
return "vérifie le chemin, montre le diff et demande ta confirmation"
if name == "run_command":
command = arguments.get("command", "") if isinstance(arguments, dict) else ""
if is_allowed_command(command, ctx.allowed_commands):
return "commande autorisée : lancée sans confirmation"
return "demande ta confirmation avant de lancer la commande"
if name == "load_skill":
return "vérifie que la skill existe et la charge"
return f"vérifie le chemin et exécute {name}"
def run_turn(chat: ChatFunction, messages: list[dict], ctx: ToolContext, max_tours: int = MAX_TOURS) -> str:
"""Traite le dernier message utilisateur et renvoie la réponse finale.
`messages` est modifiée sur place : on y ajoute les messages de
l'assistant et les résultats des outils, pour garder tout l'historique.
L'affichage montre chaque tour : le modèle demande, le harness décide,
l'outil répond.
"""
previous_call = None
for tour in range(1, max_tours + 1):
print(ui.tour_header(tour))
reply = chat(messages, tool_schemas())
# 1) On garde le message de l'assistant TEL QUEL, avec ses tool_calls :
# au prochain appel, le modèle doit voir ce qu'il a demandé.
messages.append(reply)
tool_calls = reply.get("tool_calls") or []
if not tool_calls:
print(ui.role_line("modèle", "répond sans demander d'outil : fin de la boucle"))
print(ui.answer_header(tour))
return reply.get("content", "")
for call in tool_calls:
name = call["function"]["name"]
arguments = call["function"].get("arguments", {})
print(ui.model_asks(name, arguments))
# Détection de boucle : le même appel deux fois de suite ne
# donnera pas un résultat différent, on le signale au modèle.
signature = (name, json.dumps(arguments, sort_keys=True, ensure_ascii=False))
repeated = signature == previous_call
print(ui.role_line("harness", describe_harness(name, arguments, ctx, repeated)))
if repeated:
result = (
f"Erreur : tu viens déjà d'appeler {name} avec exactement ces arguments. "
"Le résultat sera le même : change d'approche ou donne ta réponse finale."
)
else:
result = execute_tool(name, arguments, ctx)
previous_call = signature
shortened = truncate(result, MAX_OUTPUT_CHARS)
print(ui.tool_result(shortened, truncated=len(shortened) < len(result)))
# 2) Puis le résultat de l'outil, avec le rôle "tool".
messages.append({"role": "tool", "tool_name": name, "content": shortened})
print(ui.role_line("harness", f"limite de {max_tours} tours atteinte : la boucle s'arrête", ui.RED))
return f"Arrêt : limite de {max_tours} tours atteinte sans réponse finale.""""La session : tout l'état d'une conversation avec l'agent.
Jusqu'à l'étape 8, cli.py gardait la configuration et les messages dans des
variables locales. Pour pouvoir changer de modèle ou de température en cours
de route (/model, /temp...), on les regroupe dans un objet Session que les
commandes peuvent modifier.
"""
import re
from contextlib import nullcontext
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Callable
from llamagent import llm
from llamagent.agent import build_system_prompt, run_turn
from llamagent.config import MAX_OUTPUT_CHARS, PROJECT_DIR_NAME, PROJECT_INSTRUCTIONS, PROMPTS_DIR, SKILLS_DIR, Config
from llamagent.guards import GuardError, safe_path, truncate
from llamagent.tools import ToolContext, available_skills
# ─────────────────────────── Projet (étape 9) ───────────────────────────
def project_skills_dirs(workdir: Path) -> list[Path]:
"""Skills globales (dépôt llamagent), puis skills du projet, qui l'emportent."""
return [SKILLS_DIR, workdir / PROJECT_DIR_NAME / "skills"]
def load_project_instructions(workdir: Path) -> str:
"""Contenu du fichier AGENTS.md du projet, ou "" s'il n'y en a pas."""
path = workdir / PROJECT_INSTRUCTIONS
if not path.is_file():
return ""
# Tronqué : le prompt système est renvoyé à chaque appel, il doit rester court.
return truncate(path.read_text(encoding="utf-8"), MAX_OUTPUT_CHARS)
# ─────────────────────────── Fichiers tagués (étape 11) ───────────────────────────
# « @chemin » en début de texte ou après un espace : une adresse e-mail
# comme nils@exemple.fr n'est donc pas prise pour un fichier.
MENTION = re.compile(r"(?<!\S)@([\w./\\-]+)")
def find_mentions(text: str) -> list[str]:
"""Chemins tagués avec @ dans la demande, sans doublon, dans l'ordre."""
# rstrip : dans « regarde @main.py. » le point final n'est pas au fichier.
paths = [match.rstrip(".,;:") for match in MENTION.findall(text)]
return list(dict.fromkeys(path for path in paths if path))
def expand_mentions(text: str, workdir: Path) -> str:
"""Ajoute à la demande le contenu des fichiers tagués avec @chemin.
Le modèle reçoit directement le contenu : pas besoin d'un appel à
read_file, donc un tour de boucle en moins.
"""
blocks = []
for path in find_mentions(text):
try:
content = truncate(safe_path(workdir, path).read_text(encoding="utf-8"), MAX_OUTPUT_CHARS)
except (GuardError, OSError, UnicodeDecodeError) as error:
content = f"(fichier illisible : {error})"
blocks.append(f"--- {path} ---\n{content}")
if not blocks:
return text
return text + "\n\nFichiers mentionnés :\n\n" + "\n\n".join(blocks)
# ─────────────────────────── La session ───────────────────────────
TITLE_LENGTH = 60 # longueur maximale du titre d'une sauvegarde (étape 15)
@dataclass
class Session:
"""L'état d'une conversation : réglages, outils, messages."""
config: Config
template: str # contenu de prompts/AGENT.md
ctx: ToolContext
messages: list[dict] = field(default_factory=list)
context_tokens: int = 0 # taille du dernier échange, d'après Ollama
context_estimated: bool = False # vrai si context_tokens est une estimation (étape 16)
session_id: str | None = None # nom du fichier de sauvegarde (étape 15)
title: str = "" # titre de la sauvegarde : début de la première demande
# Ce qui s'affiche pendant l'attente du modèle (étape 17) : rien par défaut,
# l'animation de ui.thinking dans la vraie interface.
waiting: Callable = nullcontext
def __post_init__(self) -> None:
self.reset()
def system_prompt(self) -> str:
skills = available_skills(self.ctx.skills_dirs)
instructions = load_project_instructions(self.config.workdir)
return build_system_prompt(self.template, self.config.workdir, skills, instructions)
def reset(self) -> None:
"""Repart d'une conversation vide. Le prompt système est relu :
une skill ou un AGENTS.md modifié est pris en compte."""
self.messages = [{"role": "system", "content": self.system_prompt()}]
self.context_tokens = 0
# Nouvelle conversation : elle sera sauvegardée dans un nouveau fichier.
self.session_id = None
self.title = ""
def remember_title(self) -> None:
"""Retient le début de la première demande comme titre (étape 15).
Le titre est fixé une fois pour toutes : après un compactage, la
première demande a disparu, mais le titre reste le même.
"""
if not self.title:
first = next((m["content"] for m in self.messages if m["role"] == "user"), "")
self.title = first.splitlines()[0][:TITLE_LENGTH] if first else ""
def update(self, **changes) -> None:
"""Change un réglage (modèle, température...) en gardant la conversation."""
self.config = replace(self.config, **changes)
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
"""La fonction `chat` donnée à la boucle. Elle lit self.config à chaque
appel : un /model ou un /temp s'applique dès l'appel suivant."""
with self.waiting():
data = llm.chat_full(self.config, messages, tools)
# Tokens lus + tokens générés : la place occupée dans le contexte.
self.context_tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
self.context_estimated = False
return data["message"]
def ask(self, text: str) -> str:
"""Traite une demande de l'utilisateur et renvoie la réponse finale."""
self.messages.append({"role": "user", "content": expand_mentions(text, self.config.workdir)})
return run_turn(self.chat, self.messages, self.ctx)
def create_session(config: Config, confirm: Callable[[str], bool]) -> Session:
"""Assemble une session : prompt système, outils, skills du projet."""
ctx = ToolContext(
workdir=config.workdir,
skills_dirs=project_skills_dirs(config.workdir),
confirm=confirm,
allowed_commands=list(config.allowed_commands),
)
template = (PROMPTS_DIR / "AGENT.md").read_text(encoding="utf-8")
return Session(config=config, template=template, ctx=ctx)"""Sauvegarde des conversations, pour les reprendre plus tard (étape 15).
Chaque conversation est un fichier JSON dans .llamagent/sessions/ du
projet. Il est réécrit après chaque demande : en cas d'arrêt brutal, on
perd au plus la demande en cours.
"""
import json
from datetime import datetime
from pathlib import Path
from llamagent.config import PROJECT_DIR_NAME
from llamagent.context import estimate_messages
from llamagent.session import Session
def sessions_dir(workdir: Path) -> Path:
return workdir / PROJECT_DIR_NAME / "sessions"
def save_session(session: Session) -> None:
"""Enregistre la conversation, sans le prompt système (reconstruit à la reprise)."""
conversation = session.messages[1:]
if not any(m["role"] == "user" for m in conversation):
return # conversation vide : rien à sauvegarder
now = datetime.now()
if session.session_id is None:
session.session_id = now.strftime("%Y-%m-%d_%H-%M-%S")
session.remember_title()
data = {
"id": session.session_id,
"title": session.title,
"updated": now.isoformat(timespec="seconds"),
"model": session.config.model,
"messages": conversation,
}
folder = sessions_dir(session.config.workdir)
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{session.session_id}.json"
# On écrit dans un fichier temporaire, puis on le renomme : un Ctrl+C
# pendant l'écriture ne laisse jamais un fichier à moitié écrit.
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
temporary.replace(path)
def list_sessions(workdir: Path) -> list[dict]:
"""Les conversations sauvegardées, de la plus récente à la plus ancienne."""
folder = sessions_dir(workdir)
if not folder.is_dir():
return []
sessions = []
for path in folder.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
continue # fichier abîmé : on l'ignore plutôt que de planter
data["path"] = path # pour /delete : on supprime ce fichier-là, rien d'autre
sessions.append(data)
return sorted(sessions, key=lambda data: data.get("updated", ""), reverse=True)
def find_session(workdir: Path, ref: str) -> dict | None:
"""ref : numéro affiché par /sessions (1 = la plus récente) ou identifiant."""
sessions = list_sessions(workdir)
if ref.isdigit() and 1 <= int(ref) <= len(sessions):
return sessions[int(ref) - 1]
return next((data for data in sessions if data.get("id") == ref), None)
def resume_session(session: Session, data: dict) -> None:
"""Remplace la conversation en cours par une conversation sauvegardée."""
session.reset() # prompt système relu : AGENTS.md et skills à jour
session.messages += data["messages"]
session.session_id = data["id"]
session.title = data.get("title", "")
# Ollama ne mesurera le contexte qu'au prochain appel : en attendant, une estimation.
session.context_tokens = estimate_messages(session.messages)
session.context_estimated = True
def delete_session(data: dict) -> None:
data["path"].unlink(missing_ok=True)"""Mesurer et compacter le contexte (étape 16).
Mesurer : Ollama donne la taille totale après chaque appel (étape 13). Pour
savoir ce qui remplit le contexte, on estime la part de chaque catégorie de
messages à partir du nombre de caractères.
Compacter : quand le contexte est presque plein, on demande au modèle de
résumer la conversation, puis on remplace toute la conversation par ce
résumé. Le prompt système, lui, est gardé tel quel.
"""
import json
from llamagent import llm
from llamagent.config import COMPACT_THRESHOLD, PROMPTS_DIR
from llamagent.guards import truncate
from llamagent.tools import tool_schemas
# Approximation : environ 4 caractères par token, en français comme en code.
# Ce n'est qu'une estimation ; la vraie mesure vient d'Ollama.
CHARS_PER_TOKEN = 4
# Dans le texte à résumer, chaque résultat d'outil ou argument est raccourci.
TRANSCRIPT_ITEM_CHARS = 300
def estimate_tokens(text: str) -> int:
return len(text) // CHARS_PER_TOKEN
def message_text(message: dict) -> str:
"""Tout le texte d'un message, appels d'outils compris."""
calls = message.get("tool_calls") or []
return (message.get("content") or "") + (json.dumps(calls, ensure_ascii=False) if calls else "")
def estimate_messages(messages: list[dict]) -> int:
"""Estimation de la place occupée par des messages et par les schémas des outils."""
text = "".join(message_text(m) for m in messages) + json.dumps(tool_schemas(), ensure_ascii=False)
return estimate_tokens(text)
def context_report(session) -> str:
"""Ce qui occupe le contexte, catégorie par catégorie (commande /context)."""
parts = {
"prompt système": 0,
"schémas des outils": estimate_tokens(json.dumps(tool_schemas(), ensure_ascii=False)),
"vos messages": 0,
"réponses du modèle": 0,
"résultats d'outils": 0,
}
category = {"system": "prompt système", "user": "vos messages", "assistant": "réponses du modèle", "tool": "résultats d'outils"}
for message in session.messages:
parts[category[message["role"]]] += estimate_tokens(message_text(message))
total = sum(parts.values())
size = session.config.num_ctx
filled = min(20, round(20 * total / size))
lines = [f"{name:<20} ≈ {tokens:>6} tokens" for name, tokens in parts.items()]
lines += [
f"{'total estimé':<20} ≈ {total:>6} tokens [{'#' * filled}{'-' * (20 - filled)}] {100 * total // size} % de {size}",
f"dernier total : {session.context_tokens} tokens ({'estimation' if session.context_estimated else 'mesuré par Ollama'})",
f"compactage automatique à {int(COMPACT_THRESHOLD * 100)} %, ou maintenant avec /compact",
]
return "\n".join(lines)
def transcript(messages: list[dict]) -> str:
"""La conversation en texte simple, pour la faire résumer par le modèle."""
lines = []
for message in messages:
role = message["role"]
if role == "user":
lines.append(f"UTILISATEUR : {truncate(message['content'], 4 * TRANSCRIPT_ITEM_CHARS)}")
elif role == "assistant":
if message.get("content"):
lines.append(f"AGENT : {message['content']}")
for call in message.get("tool_calls") or []:
arguments = json.dumps(call["function"].get("arguments", {}), ensure_ascii=False)
lines.append(f"AGENT appelle {call['function']['name']} {truncate(arguments, TRANSCRIPT_ITEM_CHARS)}")
elif role == "tool":
lines.append(f"RÉSULTAT de {message.get('tool_name', '?')} : {truncate(message['content'], TRANSCRIPT_ITEM_CHARS)}")
return "\n".join(lines)
def needs_compaction(session) -> bool:
return session.context_tokens >= COMPACT_THRESHOLD * session.config.num_ctx
def compact(session) -> str:
"""Remplace la conversation par un résumé écrit par le modèle, et renvoie ce résumé."""
conversation = session.messages[1:]
if not conversation:
return ""
# Le texte à résumer doit lui-même tenir dans le contexte : au plus la
# moitié, en coupant le milieu (truncate garde le début et la fin).
text = truncate(transcript(conversation), session.config.num_ctx * CHARS_PER_TOKEN // 2)
instructions = (PROMPTS_DIR / "COMPACT.md").read_text(encoding="utf-8")
# Appel sans outils : on veut du texte, pas des tool calls.
with session.waiting():
reply = llm.chat(session.config, [{"role": "system", "content": instructions}, {"role": "user", "content": text}])
summary = (reply.get("content") or "").strip()
session.remember_title() # avant que la première demande ne disparaisse
session.messages = [
session.messages[0], # le prompt système est gardé tel quel
{"role": "user", "content": f"Résumé de notre conversation jusqu'ici (le début a été compacté) :\n\n{summary}"},
{"role": "assistant", "content": "Compris, je reprends à partir de ce résumé."},
]
session.context_tokens = estimate_messages(session.messages)
session.context_estimated = True
return summary"""Les commandes /... tapées par l'utilisateur (et non par le modèle).
Même principe que le registre d'outils : un dictionnaire
nom -> (fonction, aide). Ajouter une commande ne touche ni à la boucle
ni à cli.py. Chaque fonction reçoit la session et le texte tapé après la
commande, et renvoie le texte à afficher.
"""
from typing import Callable
import requests
from llamagent import llm
from llamagent.context import compact, context_report
from llamagent.config import PROJECT_DIR_NAME, PROJECT_INSTRUCTIONS
from llamagent.guards import SHELL_OPERATORS, GuardError
from llamagent.history import delete_session, find_session, list_sessions, resume_session, save_session
from llamagent.session import Session
from llamagent.tools import available_skills, load_skill
def cmd_help(session: Session, arg: str) -> str:
lines = [f"{name:<9} {description}" for name, (_, description) in COMMANDS.items()]
return "\n".join(lines + [f"{'/quit':<9} quitter", "", "@chemin dans une demande : joint le contenu du fichier."])
# Fichiers créés par /init dans le projet : chemin relatif -> contenu.
INIT_FILES = {
PROJECT_INSTRUCTIONS: """# Instructions du projet
- But du projet : ...
- Lancer le projet : ...
- Lancer les tests : python -m pytest -q
- Conventions de code : ...
- Fichiers ou dossiers à ne jamais modifier : ...
""",
f"{PROJECT_DIR_NAME}/config.toml": """# Réglages de llamagent pour ce projet (ils remplacent ~/.llamagent/config.toml).
# Retire le # devant une ligne pour l'activer.
# model = "qwen3.5:4b"
# temperature = 0.2
# num_ctx = 8192
# allowed_commands = ["python -m pytest", "git status", "git diff"]
""",
# Les conversations sauvegardées contiennent le contenu des fichiers lus :
# elles ne doivent pas finir dans le dépôt git du projet.
f"{PROJECT_DIR_NAME}/.gitignore": "sessions/\n",
f"{PROJECT_DIR_NAME}/skills/LISEZMOI.txt": """Skills de ce projet : un fichier .md par skill, par exemple deployer.md.
Format :
---
description: Une ligne qui dit quand utiliser cette skill.
---
# Titre
1. Première étape de la méthode.
2. ...
Le prompt système ne contient que le nom et la description de chaque skill.
Le modèle charge le contenu avec load_skill quand la demande correspond.
Une skill de ce dossier remplace une skill globale du même nom.
Ce fichier .txt n'est pas une skill : il est ignoré.
""",
}
def cmd_init(session: Session, arg: str) -> str:
workdir = session.config.workdir
# On ne remplace jamais un fichier existant : seuls les manquants sont créés.
missing = [rel for rel in INIT_FILES if not (workdir / rel).exists()]
if not missing:
return "Ce projet est déjà initialisé : rien à créer."
listing = "\n".join(f" {rel}" for rel in missing)
if not session.ctx.confirm(f"Fichiers à créer :\n{listing}\nLes créer dans {workdir} ?"):
return "Rien n'a été créé."
for rel in missing:
path = workdir / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(INIT_FILES[rel], encoding="utf-8")
# Le prompt système est reconstruit pour tenir compte d'AGENTS.md,
# sans effacer la conversation en cours.
session.messages[0] = {"role": "system", "content": session.system_prompt()}
return f"Créé :\n{listing}\nComplète AGENTS.md, puis ajoute tes skills dans {PROJECT_DIR_NAME}/skills/."
def cmd_config(session: Session, arg: str) -> str:
c = session.config
return (
f"modèle : {c.model}\n"
f"serveur : {c.ollama_url}\n"
f"température : {c.temperature}\n"
f"contexte : {c.num_ctx} tokens\n"
f"dossier : {c.workdir}\n"
f"sans confirmer : {', '.join(session.ctx.allowed_commands) or 'aucune commande'}"
)
def cmd_models(session: Session, arg: str) -> str:
models = llm.list_models(session.config)
return "\n".join(("* " if name == session.config.model else " ") + name for name in models) or "(aucun modèle)"
def cmd_model(session: Session, arg: str) -> str:
if not arg:
return f"Modèle actuel : {session.config.model}"
models = llm.list_models(session.config)
# « qwen3.5 » désigne « qwen3.5:latest » pour Ollama.
name = arg if arg in models else f"{arg}:latest"
if name not in models:
return f"Modèle inconnu : {arg}. Tape /models pour voir les modèles installés."
session.update(model=name)
return f"Modèle : {name} (la conversation continue avec ce modèle)"
def cmd_temp(session: Session, arg: str) -> str:
if not arg:
return f"Température actuelle : {session.config.temperature}"
try:
value = float(arg)
except ValueError:
return "Usage : /temp 0.2"
if not 0 <= value <= 2:
return "La température doit être comprise entre 0 et 2."
session.update(temperature=value)
return f"Température : {value}"
def cmd_ctx(session: Session, arg: str) -> str:
if not arg:
return f"Contexte : {session.config.num_ctx} tokens"
try:
value = int(arg)
except ValueError:
return "Usage : /ctx 8192"
if not 2048 <= value <= 131072:
return "Le contexte doit être compris entre 2048 et 131072 tokens."
session.update(num_ctx=value)
return f"Contexte : {value} tokens (plus de contexte = plus de VRAM utilisée)"
def cmd_skills(session: Session, arg: str) -> str:
skills = available_skills(session.ctx.skills_dirs)
return "\n".join(f"{name} : {description}" for name, description in skills.items()) or "(aucune skill)"
def cmd_skill(session: Session, arg: str) -> str:
if not arg:
return "Usage : /skill nom (voir /skills)"
try:
content = load_skill(session.ctx, arg)
except GuardError as error:
return str(error)
# Ajoutée à la conversation : le modèle la verra dès la prochaine demande.
session.messages.append({"role": "user", "content": f"Pour la suite, suis les instructions de la skill « {arg} » :\n\n{content}"})
return f"Skill {arg} chargée dans la conversation."
def cmd_clear(session: Session, arg: str) -> str:
session.reset()
return "Conversation effacée. Prompt système, skills et AGENTS.md relus."
def cmd_sessions(session: Session, arg: str) -> str:
sessions = list_sessions(session.config.workdir)
if not sessions:
return "Aucune conversation sauvegardée dans ce projet."
lines = []
for number, data in enumerate(sessions, start=1):
current = "*" if data["id"] == session.session_id else " "
date = data.get("updated", "?").replace("T", " ")[:16]
lines.append(f"{current}{number:>3}. {date} {len(data.get('messages', [])):>3} messages {data.get('title', '')}")
return "\n".join(lines + ["", "/resume <numéro> pour reprendre, /delete <numéro> pour supprimer."])
def cmd_resume(session: Session, arg: str) -> str:
# Sans argument : la conversation la plus récente.
data = find_session(session.config.workdir, arg or "1")
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
resume_session(session, data)
return f"Conversation reprise : {data.get('title', '')} ({len(data['messages'])} messages)."
def cmd_delete(session: Session, arg: str) -> str:
if not arg:
return "Usage : /delete <numéro> (voir /sessions)"
data = find_session(session.config.workdir, arg)
if data is None:
return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
if not session.ctx.confirm(f"Supprimer la conversation « {data.get('title', '')} » ?"):
return "Rien n'a été supprimé."
delete_session(data)
if data["id"] == session.session_id:
session.session_id = None # la conversation en cours sera sauvegardée dans un nouveau fichier
return f"Conversation supprimée : {data.get('title', '')}"
def cmd_context(session: Session, arg: str) -> str:
return context_report(session)
def cmd_compact(session: Session, arg: str) -> str:
if len(session.messages) <= 1:
return "Rien à compacter : la conversation est vide."
summary = compact(session)
save_session(session) # le fichier sauvegardé contient désormais le résumé
return f"Conversation compactée. Le modèle repart de ce résumé :\n\n{summary}"
def cmd_allow(session: Session, arg: str) -> str:
allowed = session.ctx.allowed_commands
if not arg:
return "Commandes lancées sans confirmation : " + (", ".join(allowed) or "aucune")
if any(operator in arg for operator in SHELL_OPERATORS):
return "Refusé : une commande autorisée ne doit contenir aucun opérateur shell (& | ; > < ...)."
allowed.append(arg)
return f"« {arg} » sera lancée sans confirmation jusqu'à la fin de la session."
# nom de la commande -> (fonction, aide affichée par /help)
COMMANDS: dict[str, tuple[Callable[[Session, str], str], str]] = {
"/help": (cmd_help, "liste des commandes"),
"/init": (cmd_init, "préparer le projet : AGENTS.md et dossier .llamagent/"),
"/config": (cmd_config, "réglages actuels"),
"/models": (cmd_models, "modèles installés sur le serveur Ollama"),
"/model": (cmd_model, "changer de modèle : /model qwen3.5:4b"),
"/temp": (cmd_temp, "changer la température : /temp 0.5"),
"/ctx": (cmd_ctx, "changer la taille du contexte : /ctx 16384"),
"/skills": (cmd_skills, "skills disponibles"),
"/skill": (cmd_skill, "charger une skill : /skill corriger-un-bug"),
"/clear": (cmd_clear, "effacer la conversation"),
"/context": (cmd_context, "ce qui occupe le contexte"),
"/compact": (cmd_compact, "résumer la conversation pour libérer du contexte"),
"/sessions": (cmd_sessions, "conversations sauvegardées dans ce projet"),
"/resume": (cmd_resume, "reprendre une conversation : /resume 2 (sans numéro : la dernière)"),
"/delete": (cmd_delete, "supprimer une conversation : /delete 2"),
"/allow": (cmd_allow, "lancer une commande sans confirmation : /allow python -m pytest"),
}
def execute_command(session: Session, line: str) -> str:
"""Exécute une ligne comme « /temp 0.5 » et renvoie le texte à afficher."""
name, _, arg = line.strip().partition(" ")
if name not in COMMANDS:
return f"Commande inconnue : {name}. Tape /help."
function, _ = COMMANDS[name]
try:
return function(session, arg.strip())
except requests.RequestException as error:
return f"Erreur de communication avec Ollama : {error}""""L'interface de llamagent, sans dépendance (étape 17).
Deux idées :
- une mascotte, dessinée pour le projet, accueille l'utilisateur ;
- chaque tour de la boucle agentique est affiché avec ses trois acteurs :
le modèle (ce qu'il demande), le harness (ce que les garde-fous en font)
et l'outil (ce qu'il renvoie). On voit la boucle du tutoriel tourner.
Tout passe par des codes ANSI : « \\033[...m » change la couleur ou le style
du texte qui suit, « \\033[0m » revient au style normal. Les couleurs sont
celles du site (bleu-vert et orange), prises dans la palette de 256 couleurs.
Si la sortie n'est pas un terminal (redirection vers un fichier) ou si
NO_COLOR est défini, on affiche du texte simple.
"""
import itertools
import json
import os
import re
import shutil
import sys
import threading
import time
from contextlib import contextmanager
def _supports_style() -> bool:
return sys.stdout.isatty() and "NO_COLOR" not in os.environ
def _supports_unicode() -> bool:
"""Vrai si la console sait afficher la mascotte et les traits."""
try:
"┌┴┐│└┬┘─●▬█░►…".encode(sys.stdout.encoding or "ascii")
return True
except (UnicodeEncodeError, LookupError):
return False
STYLE = _supports_style()
UNICODE = _supports_unicode()
# Codes de style. « 38;5;N » choisit la couleur N de la palette de 256.
BOLD = "1"
TEAL = "38;5;38" # le bleu-vert du site
ORANGE = "38;5;208" # l'orange du site
GREY = "38;5;245"
LIGHT = "38;5;252"
GREEN = "38;5;114"
RED = "38;5;203"
YELLOW = "38;5;221"
CODE = "38;5;117"
# La mascotte : un petit robot, antenne orange, tête bleu-vert.
MASCOT = [
" ● ",
" ┌─┴─┐",
" │o o│",
" │ ▬ │",
" └┬─┬┘",
]
MASCOT_ASCII = [
" o ",
" .-+-.",
" |o o|",
" | = |",
" '+-+'",
]
# Les trois acteurs de la boucle, plus l'utilisateur.
ROLE_STYLE = {"vous": ORANGE, "modèle": TEAL, "harness": LIGHT, "outil": GREEN}
ROLE_WIDTH = 9 # assez pour « harness » et deux espaces
INDENT = " " * (2 + ROLE_WIDTH) # pour aligner les lignes suivantes sous le texte
ANSI_CODE = re.compile(r"\033\[[0-9;]*[mK]")
def style(text: str, *codes: str) -> str:
"""Entoure le texte des codes ANSI demandés (ou le laisse tel quel)."""
if not STYLE or not codes:
return text
return f"\033[{';'.join(codes)}m{text}\033[0m"
def visible_len(text: str) -> int:
"""Longueur affichée d'un texte : les codes ANSI ne prennent pas de place."""
return len(ANSI_CODE.sub("", text))
def symbol(fancy: str, plain: str) -> str:
return fancy if UNICODE else plain
def shorten(text: str, width: int) -> str:
"""Raccourcit par le début (on garde la fin d'un chemin, la plus parlante)."""
if len(text) <= width:
return text
return symbol("…", "...") + text[-(width - 1):]
# ─────────────────────────── Démarrage ───────────────────────────
def mascot() -> list[str]:
lines = []
for number, line in enumerate(MASCOT if UNICODE else MASCOT_ASCII):
chars = []
for char in line:
if char == " ":
chars.append(char)
elif number == 0:
chars.append(style(char, ORANGE, BOLD)) # l'antenne
elif char == "o":
chars.append(style(char, BOLD)) # les yeux
else:
chars.append(style(char, TEAL))
lines.append("".join(chars))
return lines
def banner(rows: list[tuple[str, str]]) -> str:
"""La mascotte, le titre, puis les réglages de la session."""
dot = symbol(" · ", " - ")
hint = (
style("/help", ORANGE) + style(" commandes", GREY) + style(dot, GREY)
+ style("@fichier", ORANGE) + style(" joindre un fichier", GREY) + style(dot, GREY)
+ style("/quit", ORANGE) + style(" quitter", GREY)
)
beside = ["", style("llamagent", BOLD, TEAL), style("un agent de code minimal autour d’Ollama", GREY), "", hint]
top = [f"{line} {text}".rstrip() for line, text in zip(mascot(), beside)]
width = shutil.get_terminal_size().columns - ROLE_WIDTH - 4
label_width = max(len(label) for label, _ in rows)
info_lines = [f" {style(label.ljust(label_width), GREY)} {shorten(value, width)}" for label, value in rows]
return "\n" + "\n".join(top) + "\n\n" + "\n".join(info_lines)
# ─────────────────────────── La boucle ───────────────────────────
def role(name: str) -> str:
"""L'étiquette d'un acteur, en couleur, sur une largeur fixe."""
return " " + style(name.ljust(ROLE_WIDTH), ROLE_STYLE[name], BOLD)
def role_line(name: str, text: str, *codes: str) -> str:
return role(name) + style(text, *codes)
def prompt() -> str:
"""L'invite : l'utilisateur est le premier acteur de la conversation."""
return "\n" + role("vous")
def rule(label: str) -> str:
"""Un trait de séparation avec un titre : « ── tour 2 ─────── »."""
h = symbol("─", "-")
width = min(64, shutil.get_terminal_size().columns - 2)
start = f"{h * 2} {label} "
return style(start + h * max(3, width - len(start)), GREY)
def tour_header(tour: int) -> str:
return "\n" + rule(f"tour {tour}")
def call_text(name: str, arguments) -> str:
"""« edit_file(calc.py, …) » : le nom de l'outil et son premier argument."""
if isinstance(arguments, dict) and arguments:
values = list(arguments.values())
detail = str(values[0]) + (", " + symbol("…", "...") if len(values) > 1 else "")
elif isinstance(arguments, dict):
detail = ""
else:
detail = json.dumps(arguments, ensure_ascii=False)
if len(detail) > 60:
detail = detail[:59] + symbol("…", "...")
return f"{name}({detail})"
def model_asks(name: str, arguments) -> str:
return role_line("modèle", "demande ") + style(call_text(name, arguments), BOLD)
def tool_result(result: str, truncated: bool = False) -> str:
is_error = result.startswith(("Erreur", "Refusé"))
lines = result.splitlines() or [""]
preview = lines[0][:90] + (f" (+{len(lines) - 1} lignes)" if len(lines) > 1 else "")
if truncated:
preview += " [tronqué]"
return role_line("outil", preview, RED if is_error else GREY)
def answer_header(tour: int) -> str:
calls = f"{tour} appel{'s' if tour > 1 else ''} au modèle"
return "\n" + rule(f"réponse {symbol('·', '-')} {calls}")
@contextmanager
def thinking(label: str = "réfléchit"):
"""Animation dans la colonne du modèle pendant qu'on attend sa réponse.
Elle tourne dans un fil d'exécution (thread) séparé : le programme
principal, lui, est bloqué dans la requête HTTP. « \\r » ramène le
curseur en début de ligne, « \\033[K » efface la ligne à la fin.
"""
if not STYLE:
yield
return
stop = threading.Event()
def animate() -> None:
start = time.monotonic()
for dots in itertools.cycle([" ", ". ", ".. ", "..."]):
elapsed = time.monotonic() - start
sys.stdout.write(f"\r{role('modèle')}{style(f'{label}{dots} {elapsed:.0f} s', GREY)}")
sys.stdout.flush()
if stop.wait(0.3): # attend 0,3 s, ou s'arrête si on le lui demande
break
sys.stdout.write("\r\033[K")
sys.stdout.flush()
thread = threading.Thread(target=animate, daemon=True)
thread.start()
try:
yield
finally:
stop.set()
thread.join()
def confirm(question: str) -> bool:
"""Comme guards.ask_confirmation, avec le diff en couleurs, aligné sous le harness."""
lines = question.splitlines() or [""]
for line in lines[:-1]:
if line.startswith(("+++", "---")):
line = style(line, GREY)
elif line.startswith("+"):
line = style(line, GREEN)
elif line.startswith("-"):
line = style(line, RED)
elif line.startswith("@@"):
line = style(line, CODE)
print(INDENT + line)
reply = input(f"{INDENT}{style('?', ORANGE, BOLD)} {lines[-1]} {style('[o/N]', BOLD)} ")
return reply.strip().lower() in ("o", "oui")
# ─────────────────────────── Réponse ───────────────────────────
INLINE_CODE = re.compile(r"`([^`]+)`")
BOLD_TEXT = re.compile(r"\*\*(.+?)\*\*")
BULLET = re.compile(r"^(\s*)[-*] (.*)")
def render_markdown(text: str) -> str:
"""Un rendu très simple du Markdown des réponses : titres, gras, `code`,
listes et blocs de code. Sans couleurs, le texte reste tel quel."""
if not STYLE:
return text
lines, in_code = [], False
for line in text.splitlines():
if line.strip().startswith("```"):
in_code = not in_code # les lignes ``` elles-mêmes ne sont pas affichées
continue
if in_code:
lines.append(style(symbol("│ ", "| "), GREY) + style(line, CODE))
continue
if line.startswith("#"):
lines.append(style(line.lstrip("#").strip(), BOLD, TEAL))
continue
bullet = BULLET.match(line)
if bullet:
line = f"{bullet.group(1)}{style(symbol('►', '-'), ORANGE)} {bullet.group(2)}"
line = BOLD_TEXT.sub(lambda match: style(match.group(1), BOLD), line)
line = INLINE_CODE.sub(lambda match: style(match.group(1), CODE), line)
lines.append(line)
return "\n".join(lines)
def answer(text: str) -> str:
"""La réponse finale, décalée sous le trait « réponse »."""
return "\n".join(" " + line for line in render_markdown(text).splitlines())
def context_gauge(model: str, used: int, size: int, estimated: bool) -> str:
"""Une jauge de remplissage du contexte, affichée sous chaque réponse."""
ratio = min(1.0, used / size)
filled = round(ratio * 10)
bar = symbol("█", "#") * filled + symbol("░", "-") * (10 - filled)
level = RED if used >= size else YELLOW if used >= 0.75 * size else TEAL
dot = symbol(" · ", " - ")
approx = symbol("≈", "~") if estimated else ""
text = style(f" contexte {bar}", level) + style(f" {approx}{used} / {size} tokens ({int(ratio * 100)} %){dot}{model}", GREY)
if used >= size:
text += style(" plein : Ollama a coupé le début de la conversation", RED)
return text
# ─────────────────────────── Messages ───────────────────────────
def info(text: str) -> str:
"""Un message secondaire, aligné sous le texte (fichier joint...)."""
return style(f"{INDENT}{text}", GREY)
def warning(text: str) -> str:
return style(f" {text}", YELLOW)
def error(text: str) -> str:
return style(f" {symbol('×', 'x')} {text}", RED)
def indent(text: str) -> str:
"""Décale un bloc de texte (sortie des commandes /...)."""
return "\n".join(" " + line for line in text.splitlines())"""Point d'entrée de la ligne de commande : `llamagent`.
llamagent # mode conversation dans le dossier courant
llamagent "corrige le bug..." # une seule demande, puis on quitte
llamagent -m qwen3.5:9b -t 0.5 # options : voir llamagent --help
C'est ici qu'on assemble le harness : configuration, session (prompt
système, outils, garde-fous), commandes /..., puis la boucle agentique.
Tout l'affichage passe par ui.py (étape 17).
"""
import argparse
import os
from importlib.metadata import version
import requests
from llamagent import ui
from llamagent.commands import execute_command
from llamagent.config import PROJECT_DIR_NAME, load_config
from llamagent.context import compact, needs_compaction
from llamagent.history import find_session, resume_session, save_session
from llamagent.session import Session, create_session, find_mentions, load_project_instructions
from llamagent.tools import available_skills
# Nom du paquet sur PyPI (étape 18), utilisé pour lire la version installée.
PACKAGE_NAME = "llamagent"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="llamagent", description="Un agent de code minimal autour d'Ollama.")
parser.add_argument("demande", nargs="*", help="demande à traiter avant de quitter (sinon : mode conversation)")
parser.add_argument("-m", "--model", help="modèle Ollama, ex. qwen3.5:4b")
parser.add_argument("-t", "--temperature", type=float, help="température, ex. 0.2")
parser.add_argument("--num-ctx", type=int, help="taille du contexte en tokens, ex. 8192")
parser.add_argument("--url", help="adresse du serveur Ollama")
parser.add_argument("-c", "--continue", dest="resume", action="store_true", help="reprendre la dernière conversation du projet")
parser.add_argument("--version", action="version", version=f"llamagent {version(PACKAGE_NAME)}")
return parser.parse_args(argv)
def context_status(session: Session) -> str:
"""La jauge de contexte affichée sous chaque réponse."""
c = session.config
return ui.context_gauge(c.model, session.context_tokens, c.num_ctx, session.context_estimated)
def handle_request(session: Session, text: str) -> None:
"""Envoie une demande à l'agent et affiche sa réponse."""
for path in find_mentions(text):
print(ui.info(f"@{path} joint au message"))
try:
answer = session.ask(text)
except requests.RequestException as error:
print(ui.error(f"Erreur de communication avec Ollama : {error}"))
return
print(ui.answer(answer))
print("\n" + context_status(session))
# Étape 16 : entre deux demandes, on compacte si le contexte est presque plein.
if needs_compaction(session):
print(ui.warning("Contexte presque plein : compactage automatique de la conversation..."))
try:
compact(session)
print(context_status(session))
except requests.RequestException as error:
print(ui.error(f"Compactage impossible : {error}"))
# Étape 15 : la conversation est sauvegardée après chaque demande.
save_session(session)
def project_summary(session: Session) -> str:
"""Une ligne qui résume ce que le projet apporte : AGENTS.md, skills, réglages."""
workdir = session.config.workdir
has_instructions = bool(load_project_instructions(workdir))
parts = ["AGENTS.md"] if has_instructions else []
parts.append(f"{len(available_skills(session.ctx.skills_dirs))} skills")
if (workdir / PROJECT_DIR_NAME / "config.toml").is_file():
parts.append("config.toml")
if not has_instructions:
parts.append("/init pour préparer le projet")
return ui.symbol(" · ", " - ").join(parts)
def main() -> None:
if os.name == "nt":
os.system("") # active les codes ANSI (couleurs) dans les consoles Windows
args = parse_args()
config = load_config({"model": args.model, "temperature": args.temperature, "num_ctx": args.num_ctx, "url": args.url})
session = create_session(config, confirm=ui.confirm)
session.waiting = ui.thinking # animation pendant l'attente du modèle
rows = [("modèle", config.model), ("serveur", config.ollama_url), ("dossier", str(config.workdir)), ("projet", project_summary(session))]
if args.resume:
data = find_session(config.workdir, "1")
if data is None:
rows.append(("reprise", "aucune conversation sauvegardée : nouvelle conversation"))
else:
resume_session(session, data)
rows.append(("reprise", f"{data.get('title', '')} ({len(data['messages'])} messages)"))
print(ui.banner(rows))
try:
# Mode « une seule demande » : la demande est passée en argument.
if args.demande:
handle_request(session, " ".join(args.demande))
return
while True:
text = input(ui.prompt()).strip()
if text in ("/quit", "/exit"):
break
if text.startswith("/"):
print(ui.indent(execute_command(session, text)))
elif text:
handle_request(session, text)
except (KeyboardInterrupt, EOFError):
print("\n" + ui.style(" Au revoir.", ui.GREY))
if __name__ == "__main__":
main()"""llamagent : un agent de code minimal autour d'Ollama."""
Tu es llamagent, un assistant de programmation qui travaille dans un dossier de projet.
## Règles
- Utilise les outils pour lire et modifier les fichiers. N'invente jamais le contenu d'un fichier : lis-le.
- Avant de modifier un fichier, lis-le avec read_file.
- Pour modifier un fichier, utilise edit_file avec un passage copié exactement depuis read_file. Pour créer un fichier, edit_file avec old_text vide.
- Pour lister les fichiers, utilise list_files, pas run_command.
- Les chemins sont relatifs au dossier de travail.
- Après une correction, vérifie-la avec run_command quand c'est possible.
- N'utilise que les outils fournis.
- Quand la tâche est terminée, réponds sans appeler d'outil et explique en quelques phrases ce que tu as fait.
- Réponds en français.
## Environnement
- Système : {os}
- Les commandes sont exécutées par : {shell}
- Dossier de travail : {workdir}
- Pour lancer Python, utilise `python` (exemples : `python script.py`, `python -m pytest`).
## Skills
Une skill est une fiche d'instructions pour un type de tâche. Si la demande correspond à une skill, charge-la d'abord avec load_skill, puis suis ses instructions.
{skills}Tu résumes une conversation entre un utilisateur et un agent de code, pour que l'agent puisse continuer le travail sans la relire. Garde : - ce que l'utilisateur a demandé, et ce qui reste à faire ; - les fichiers lus, créés ou modifiés, et ce qui a changé dans chacun ; - les décisions prises et les erreurs rencontrées ; - les commandes utiles (tests, lancement). Règles : - 15 lignes au maximum, en français. - Pas de code complet : seulement les noms de fichiers, de fonctions et les points importants. - Écris uniquement le résumé, sans introduction.
--- description: Méthode pour trouver et corriger un bug dans un script Python. --- # Corriger un bug 1. Lance le script avec run_command pour voir l'erreur ou le résultat faux. 2. Lis le fichier concerné avec read_file. 3. Trouve la cause exacte : la ligne fautive et pourquoi elle est fausse. 4. Corrige avec edit_file en changeant le moins de choses possible : old_text = les lignes fautives copiées exactement, new_text = les lignes corrigées. 5. Relance le script avec run_command pour vérifier que le bug a disparu. 6. Réponds en expliquant : la cause du bug, la correction, la vérification.
--- description: Écrire des tests pytest pour un module Python. --- # Écrire des tests pytest 1. Lis le module à tester avec read_file. 2. Crée un fichier `test_<module>.py` à côté du module avec edit_file (old_text vide). 3. Écris une fonction `test_...` par comportement : un cas normal, un cas limite (liste vide, zéro...), un cas d'erreur avec `pytest.raises`. 4. Importe le module avec `from <module> import <fonction>`. 5. Lance `python -m pytest -q` avec run_command. 6. Si un test échoue, vérifie d'abord si c'est le test ou le code qui est faux. Ne modifie pas le code testé sans le dire. 7. Réponds avec la liste des tests écrits et le résultat de pytest.
"""Petit script de statistiques sur des notes.
Il contient un bug volontaire : c'est la démo de llamagent.
"""
def moyenne(notes: list[float]) -> float:
total = 0
for i in range(1, len(notes)):
total += notes[i]
return total / len(notes)
if __name__ == "__main__":
notes = [12, 15, 9, 18]
resultat = moyenne(notes)
print(f"Moyenne des notes {notes} : {resultat}")
assert resultat == 13.5, f"moyenne attendue : 13.5, obtenue : {resultat}"
print("OK")"""Tests de la configuration (config.py)."""
import pytest
from llamagent import config as config_module
from llamagent.config import DEFAULT_OLLAMA_MODEL, load_config
@pytest.fixture(autouse=True)
def environnement_propre(monkeypatch, tmp_path):
"""Chaque test part sans variables d'environnement ni fichier global."""
for name in ("OLLAMA_URL", "OLLAMA_MODEL", "LLAMAGENT_WORKDIR"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config_module, "USER_CONFIG", tmp_path / "global" / "config.toml")
monkeypatch.chdir(tmp_path)
def write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def test_valeurs_par_defaut(monkeypatch, tmp_path):
monkeypatch.setenv("OLLAMA_URL", "http://localhost:11434/")
config = load_config()
assert config.model == DEFAULT_OLLAMA_MODEL
assert config.chat_url == "http://localhost:11434/api/chat" # "/" final retiré
assert config.workdir == tmp_path.resolve()
def test_dossier_de_travail_inexistant(monkeypatch, tmp_path):
monkeypatch.setenv("LLAMAGENT_WORKDIR", str(tmp_path / "absent"))
with pytest.raises(SystemExit, match="Dossier de travail introuvable"):
load_config()
def test_ordre_des_sources(monkeypatch, tmp_path):
write(config_module.USER_CONFIG, 'model = "global"\ntemperature = 0.5\nnum_ctx = 4096\n')
write(tmp_path / ".llamagent" / "config.toml", 'model = "projet"\ntemperature = 0.7\n')
monkeypatch.setenv("OLLAMA_MODEL", "variable")
config = load_config({"temperature": 0.9, "model": None}) # option non donnée : None
assert config.num_ctx == 4096 # fichier global
assert config.model == "variable" # la variable d'environnement bat les fichiers
assert config.temperature == 0.9 # l'option de ligne de commande bat tout
def test_commandes_autorisees_depuis_le_fichier(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", 'allowed_commands = ["python -m pytest", "git status"]\n')
assert load_config().allowed_commands == ("python -m pytest", "git status")
def test_reglage_inconnu(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", "temperatur = 0.5\n")
with pytest.raises(SystemExit, match="Réglage inconnu.*temperatur"):
load_config()
def test_fichier_toml_invalide(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", "model = \n")
with pytest.raises(SystemExit, match="Fichier de réglages invalide"):
load_config()
def test_valeur_invalide(tmp_path):
write(tmp_path / ".llamagent" / "config.toml", 'temperature = "chaud"\n')
with pytest.raises(SystemExit, match="Réglage invalide"):
load_config()"""Tests des garde-fous (guards.py)."""
import pytest
from llamagent.guards import GuardError, ask_confirmation, is_allowed_command, safe_path, truncate
def test_chemin_relatif_accepte(tmp_path):
assert safe_path(tmp_path, "src/main.py") == (tmp_path / "src" / "main.py").resolve()
def test_point_point_refuse(tmp_path):
with pytest.raises(GuardError, match=r"\.\."):
safe_path(tmp_path, "../secret.txt")
def test_point_point_refuse_meme_s_il_reste_dedans(tmp_path):
with pytest.raises(GuardError):
safe_path(tmp_path, "src/../main.py")
def test_chemin_absolu_hors_du_dossier_refuse(tmp_path):
dehors = tmp_path.parent / "ailleurs.txt"
with pytest.raises(GuardError, match="sort du dossier"):
safe_path(tmp_path, str(dehors))
def test_chemin_absolu_dans_le_dossier_accepte(tmp_path):
dedans = tmp_path / "main.py"
assert safe_path(tmp_path, str(dedans)) == dedans.resolve()
def test_truncate_texte_court_inchange():
assert truncate("bonjour", 100) == "bonjour"
def test_truncate_garde_debut_et_fin():
texte = "A" * 50 + "B" * 50 + "C" * 50
resultat = truncate(texte, 40)
assert resultat.startswith("A" * 20)
assert resultat.endswith("C" * 20)
assert "110 caractères tronqués" in resultat
@pytest.mark.parametrize(
("reponse", "attendu"),
[("o", True), ("oui", True), ("O", True), ("", False), ("n", False), ("non", False), ("y", False)],
)
def test_confirmation(monkeypatch, reponse, attendu):
monkeypatch.setattr("builtins.input", lambda question: reponse)
assert ask_confirmation("Continuer ?") is attendu
@pytest.mark.parametrize(
("commande", "attendu"),
[
("python -m pytest", True),
("python -m pytest -q tests", True),
("python -m pytestx", False), # préfixe d'un autre mot
("git status", True),
("git push", False),
("git status && del *.py", False), # enchaînement
("git status; rm -rf /", False),
("python -m pytest > sortie.txt", False), # redirection
("git status | more", False),
],
)
def test_commandes_autorisees(commande, attendu):
assert is_allowed_command(commande, ["python -m pytest", "git status"]) is attendu"""Tests des outils et du registre (tools.py)."""
import time
import pytest
from llamagent.tools import TOOLS, ToolContext, available_skills, execute_tool, tool_schemas
@pytest.fixture
def ctx(tmp_path):
"""Un dossier de travail temporaire, où l'utilisateur répond toujours « oui »."""
(tmp_path / "skills").mkdir()
(tmp_path / "skills" / "demo.md").write_text(
"---\ndescription: Une skill de test.\n---\n# Demo\nFais ceci.\n", encoding="utf-8"
)
return ToolContext(workdir=tmp_path, skills_dirs=[tmp_path / "skills"], confirm=lambda question: True)
def test_cinq_outils_maximum():
assert len(TOOLS) <= 5
assert [s["function"]["name"] for s in tool_schemas()] == list(TOOLS)
def test_read_file(ctx):
(ctx.workdir / "a.txt").write_text("contenu é", encoding="utf-8")
assert execute_tool("read_file", {"path": "a.txt"}, ctx) == "contenu é"
def test_read_file_hors_du_dossier_refuse(ctx):
assert execute_tool("read_file", {"path": "../x.txt"}, ctx).startswith("Refusé")
def test_list_files_ignore_dossiers_caches(ctx):
(ctx.workdir / "src").mkdir()
(ctx.workdir / "src" / "main.py").write_text("", encoding="utf-8")
(ctx.workdir / ".venv").mkdir()
(ctx.workdir / ".venv" / "lib.py").write_text("", encoding="utf-8")
(ctx.workdir / "__pycache__").mkdir()
(ctx.workdir / "__pycache__" / "x.pyc").write_text("", encoding="utf-8")
resultat = execute_tool("list_files", {}, ctx)
assert "src/main.py" in resultat.splitlines() # toujours des "/"
assert ".venv" not in resultat
assert "__pycache__" not in resultat
def test_list_files_dossier_inexistant(ctx):
# Un dossier absent ne doit pas passer pour un dossier vide.
(ctx.workdir / "a.txt").write_text("", encoding="utf-8")
for path in ("llamagent", "a.txt"):
assert "n'est pas un dossier existant" in execute_tool("list_files", {"path": path}, ctx)
def test_edit_file_cree_un_fichier(ctx):
questions = []
ctx.confirm = lambda question: questions.append(question) or True
resultat = execute_tool("edit_file", {"path": "dir/b.txt", "old_text": "", "new_text": "ligne 1\nligne 2\n"}, ctx)
assert resultat == "Fichier dir/b.txt créé."
assert (ctx.workdir / "dir" / "b.txt").read_text(encoding="utf-8") == "ligne 1\nligne 2\n"
assert "+ligne 1" in questions[0] # l'utilisateur a vu le diff
def test_edit_file_remplace_un_passage(ctx):
(ctx.workdir / "c.py").write_text("a = 1\nb = 2\nc = 3\n", encoding="utf-8")
resultat = execute_tool("edit_file", {"path": "c.py", "old_text": "b = 2", "new_text": "b = 20"}, ctx)
assert resultat == "Fichier c.py modifié."
assert (ctx.workdir / "c.py").read_text(encoding="utf-8") == "a = 1\nb = 20\nc = 3\n"
def test_edit_file_passage_introuvable(ctx):
(ctx.workdir / "c.py").write_text("a = 1\n", encoding="utf-8")
assert "old_text introuvable" in execute_tool("edit_file", {"path": "c.py", "old_text": "z = 9", "new_text": ""}, ctx)
def test_edit_file_passage_ambigu(ctx):
(ctx.workdir / "c.py").write_text("x = 0\nx = 0\n", encoding="utf-8")
assert "apparaît 2 fois" in execute_tool("edit_file", {"path": "c.py", "old_text": "x = 0", "new_text": "x = 1"}, ctx)
def test_edit_file_ne_recree_pas_un_fichier_existant(ctx):
(ctx.workdir / "c.py").write_text("a = 1\n", encoding="utf-8")
assert "existe déjà" in execute_tool("edit_file", {"path": "c.py", "old_text": "", "new_text": "b = 2"}, ctx)
assert (ctx.workdir / "c.py").read_text(encoding="utf-8") == "a = 1\n"
def test_edit_file_refuse_par_l_utilisateur(ctx):
ctx.confirm = lambda question: False
resultat = execute_tool("edit_file", {"path": "b.txt", "old_text": "", "new_text": "x"}, ctx)
assert resultat.startswith("Refusé")
assert not (ctx.workdir / "b.txt").exists()
def test_run_command(ctx):
resultat = execute_tool("run_command", {"command": 'python -c "print(\'bonjour é\')"'}, ctx)
assert "code de sortie : 0" in resultat
assert "bonjour é" in resultat
def test_run_command_autorisee_sans_confirmation(ctx):
ctx.confirm = lambda question: pytest.fail("aucune question ne devait être posée")
ctx.allowed_commands = ["python -c"]
assert "code de sortie : 0" in execute_tool("run_command", {"command": 'python -c "print(1)"'}, ctx)
def test_run_command_refusee(ctx):
ctx.confirm = lambda question: False
assert execute_tool("run_command", {"command": "echo x"}, ctx).startswith("Refusé")
def test_run_command_timeout(ctx):
ctx.command_timeout = 1
resultat = execute_tool("run_command", {"command": 'python -c "import time; time.sleep(5)"'}, ctx)
assert "dépassé 1 s" in resultat
def test_run_command_timeout_arrete_aussi_les_sous_processus(ctx):
# parent.py lance enfant.py, qui écrit fini.txt au bout de 3 s. Tuer
# seulement le shell ne suffit pas : l'enfant survivrait (et, sous
# Windows, on attendrait sa fin car il garde la sortie ouverte).
(ctx.workdir / "enfant.py").write_text(
"import time, pathlib\ntime.sleep(3)\npathlib.Path('fini.txt').write_text('x')\n", encoding="utf-8"
)
(ctx.workdir / "parent.py").write_text(
"import subprocess, sys\nsubprocess.run([sys.executable, 'enfant.py'])\n", encoding="utf-8"
)
ctx.command_timeout = 1
debut = time.monotonic()
resultat = execute_tool("run_command", {"command": "python parent.py"}, ctx)
assert "dépassé 1 s" in resultat
assert time.monotonic() - debut < 3 # pas d'attente de l'enfant
time.sleep(4)
assert not (ctx.workdir / "fini.txt").exists() # l'enfant a bien été arrêté
def test_run_command_sans_clavier(ctx):
# Un programme qui attend une saisie reçoit « fin de fichier » au lieu de bloquer.
resultat = execute_tool("run_command", {"command": 'python -c "input()"'}, ctx)
assert "EOFError" in resultat
def test_load_skill(ctx):
assert "Fais ceci." in execute_tool("load_skill", {"name": "demo"}, ctx)
assert available_skills(ctx.skills_dirs) == {"demo": "Une skill de test."}
def test_skill_du_projet_remplace_la_skill_globale(ctx):
projet = ctx.workdir / ".llamagent" / "skills"
projet.mkdir(parents=True)
(projet / "demo.md").write_text("---\ndescription: Version du projet.\n---\nFais cela.\n", encoding="utf-8")
ctx.skills_dirs = [ctx.workdir / "skills", projet]
assert available_skills(ctx.skills_dirs) == {"demo": "Version du projet."}
assert "Fais cela." in execute_tool("load_skill", {"name": "demo"}, ctx)
def test_load_skill_inconnue(ctx):
resultat = execute_tool("load_skill", {"name": "../secret"}, ctx)
assert resultat.startswith("Refusé")
assert "demo" in resultat # la liste des skills valides est donnée
def test_outil_inconnu_donne_la_liste(ctx):
resultat = execute_tool("delete_everything", {}, ctx)
assert "outil inconnu" in resultat
assert "read_file" in resultat
def test_argument_manquant(ctx):
assert "argument manquant pour read_file : path" in execute_tool("read_file", {}, ctx)
def test_argument_inconnu(ctx):
assert "argument inconnu" in execute_tool("read_file", {"path": "a", "encoding": "utf-8"}, ctx)
def test_arguments_en_texte_json(ctx):
(ctx.workdir / "a.txt").write_text("ok", encoding="utf-8")
assert execute_tool("read_file", '{"path": "a.txt"}', ctx) == "ok"
def test_arguments_json_invalide(ctx):
assert "pas du JSON valide" in execute_tool("read_file", "{path: a.txt", ctx)
def test_exception_python_renvoyee_au_modele(ctx):
resultat = execute_tool("read_file", {"path": "absent.txt"}, ctx)
assert resultat.startswith("Erreur pendant read_file : FileNotFoundError")"""Tests de la boucle agentique (agent.py), avec un faux modèle."""
from pathlib import Path
import pytest
from llamagent.agent import build_system_prompt, describe_harness, run_turn
from llamagent.tools import ToolContext
class FakeChat:
"""Faux client Ollama : renvoie des réponses écrites à l'avance."""
def __init__(self, replies):
self.replies = list(replies)
self.calls = [] # ce que la boucle a envoyé à chaque appel
def __call__(self, messages, tools):
self.calls.append(list(messages))
return self.replies.pop(0)
def tool_call(name, **arguments):
return {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": name, "arguments": arguments}}]}
def answer(text):
return {"role": "assistant", "content": text}
@pytest.fixture
def ctx(tmp_path):
(tmp_path / "notes.txt").write_text("12, 15, 9", encoding="utf-8")
return ToolContext(workdir=tmp_path, skills_dirs=[tmp_path / "skills"], confirm=lambda question: True)
def test_reponse_directe_sans_outil(ctx):
chat = FakeChat([answer("Bonjour !")])
messages = [{"role": "user", "content": "Salut"}]
assert run_turn(chat, messages, ctx) == "Bonjour !"
assert len(chat.calls) == 1
def test_outil_execute_et_resultat_renvoye(ctx):
chat = FakeChat([tool_call("read_file", path="notes.txt"), answer("Il y a 3 notes.")])
messages = [{"role": "user", "content": "Combien de notes ?"}]
assert run_turn(chat, messages, ctx) == "Il y a 3 notes."
# La conversation contient : user, assistant (tool_calls), tool, assistant.
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
assert messages[2] == {"role": "tool", "tool_name": "read_file", "content": "12, 15, 9"}
# Au 2e appel, le modèle a bien reçu le résultat de l'outil.
assert chat.calls[1][-1]["content"] == "12, 15, 9"
def test_max_tours(ctx):
# Le modèle demande des outils sans jamais conclure.
replies = [tool_call("read_file", path="notes.txt"), tool_call("list_files", path=".")] * 5
chat = FakeChat(replies)
resultat = run_turn(chat, [{"role": "user", "content": "?"}], ctx, max_tours=4)
assert "limite de 4 tours" in resultat
assert len(chat.calls) == 4
def test_appel_repete_detecte(ctx):
chat = FakeChat([tool_call("read_file", path="notes.txt"), tool_call("read_file", path="notes.txt"), answer("fini")])
messages = [{"role": "user", "content": "?"}]
run_turn(chat, messages, ctx)
tool_results = [m["content"] for m in messages if m["role"] == "tool"]
assert tool_results[0] == "12, 15, 9"
assert "déjà d'appeler read_file" in tool_results[1]
def test_outil_inconnu_ne_plante_pas(ctx):
chat = FakeChat([tool_call("search_web", query="x"), answer("désolé")])
messages = [{"role": "user", "content": "?"}]
assert run_turn(chat, messages, ctx) == "désolé"
assert "Outils valides" in messages[2]["content"]
def test_resultat_long_tronque(ctx):
(ctx.workdir / "gros.txt").write_text("x" * 50_000, encoding="utf-8")
chat = FakeChat([tool_call("read_file", path="gros.txt"), answer("ok")])
messages = [{"role": "user", "content": "?"}]
run_turn(chat, messages, ctx)
assert len(messages[2]["content"]) < 5_000
assert "tronqués" in messages[2]["content"]
def test_prompt_systeme_rempli():
template = "OS={os} shell={shell} dossier={workdir}\n{skills}"
prompt = build_system_prompt(template, Path("/projet"), {"corriger-un-bug": "Méthode."})
assert "{" not in prompt
assert "- corriger-un-bug : Méthode." in prompt
def test_prompt_systeme_avec_agents_md():
prompt = build_system_prompt("{os}{shell}{workdir}{skills}", Path("/projet"), {}, "Utilise des tabulations.")
assert prompt.endswith("## Instructions du projet (AGENTS.md)\nUtilise des tabulations.")
def test_vrai_prompt_et_vraies_skills():
"""Le fichier prompts/AGENT.md et les skills du dépôt se chargent bien."""
from llamagent.config import PROMPTS_DIR, SKILLS_DIR
from llamagent.tools import available_skills
template = (PROMPTS_DIR / "AGENT.md").read_text(encoding="utf-8")
skills = available_skills([SKILLS_DIR])
assert set(skills) == {"corriger-un-bug", "ecrire-tests-pytest"}
prompt = build_system_prompt(template, Path("/projet"), skills)
assert "corriger-un-bug" in prompt and "{skills}" not in prompt
def test_ce_que_fait_le_harness(ctx):
assert "confirmation" in describe_harness("edit_file", {"path": "a.py"}, ctx, repeated=False)
assert "confirmation" in describe_harness("run_command", {"command": "python a.py"}, ctx, repeated=False)
ctx.allowed_commands = ["python a.py"]
assert "sans confirmation" in describe_harness("run_command", {"command": "python a.py"}, ctx, repeated=False)
assert "inconnu" in describe_harness("search_web", {}, ctx, repeated=False)
assert "pas exécuté" in describe_harness("read_file", {"path": "a.py"}, ctx, repeated=True)"""Tests de la session (session.py) : projet, fichiers tagués, réglages."""
from pathlib import Path
import pytest
from llamagent import llm
from llamagent.config import Config
from llamagent.session import create_session, expand_mentions, find_mentions
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def test_mentions():
texte = "Regarde @src/app.py et @README.md. Écris à nils@exemple.fr, puis relis @src/app.py"
assert find_mentions(texte) == ["src/app.py", "README.md"]
def test_contenu_des_fichiers_mentionnes(tmp_path):
(tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8")
texte = expand_mentions("Explique @app.py", tmp_path)
assert texte.startswith("Explique @app.py\n\nFichiers mentionnés :")
assert "--- app.py ---\nprint('ok')" in texte
def test_mention_hors_du_dossier_ou_absente(tmp_path):
texte = expand_mentions("Lis @../secret.txt et @absent.py", tmp_path)
assert "--- ../secret.txt ---\n(fichier illisible : chemin refusé" in texte
assert "--- absent.py ---\n(fichier illisible :" in texte
def test_sans_mention_le_texte_ne_change_pas(tmp_path):
assert expand_mentions("Bonjour", tmp_path) == "Bonjour"
def test_agents_md_dans_le_prompt_systeme(tmp_path, session):
assert "AGENTS.md" not in session.messages[0]["content"]
(tmp_path / "AGENTS.md").write_text("Les tests sont dans tests/.", encoding="utf-8")
session.reset() # /clear relit le fichier
assert session.messages[0]["content"].endswith("Les tests sont dans tests/.")
def test_changer_de_modele_garde_la_conversation(monkeypatch, session):
vus = []
def faux_chat_full(config, messages, tools):
vus.append((config.model, config.temperature, len(messages)))
return {"message": {"role": "assistant", "content": "ok"}, "prompt_eval_count": 1200, "eval_count": 30}
monkeypatch.setattr(llm, "chat_full", faux_chat_full)
session.ask("Bonjour")
session.update(model="qwen3.5:9b", temperature=0.6)
session.ask("Et maintenant ?")
assert vus == [("qwen3.5:4b", 0.2, 2), ("qwen3.5:9b", 0.6, 4)]
assert session.context_tokens == 1230
def test_skills_du_projet(tmp_path, session):
assert session.ctx.skills_dirs[-1] == tmp_path / ".llamagent" / "skills"
assert session.ctx.skills_dirs[0].name == "skills""""Tests des commandes /... (commands.py)."""
import pytest
from llamagent import llm
from llamagent.commands import COMMANDS, execute_command
from llamagent.config import Config
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path, monkeypatch):
monkeypatch.setattr(llm, "list_models", lambda config: ["qwen3.5:4b", "qwen3.5:latest", "llama3.2:3b"])
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def test_help_liste_toutes_les_commandes(session):
aide = execute_command(session, "/help")
assert all(name in aide for name in COMMANDS)
def test_commande_inconnue(session):
assert "Commande inconnue : /foo" in execute_command(session, "/foo")
def test_temperature(session):
assert execute_command(session, "/temp 0.7") == "Température : 0.7"
assert session.config.temperature == 0.7
assert "entre 0 et 2" in execute_command(session, "/temp 5")
assert "Usage" in execute_command(session, "/temp chaud")
assert session.config.temperature == 0.7
def test_contexte(session):
execute_command(session, "/ctx 16384")
assert session.config.num_ctx == 16384
assert "entre 2048" in execute_command(session, "/ctx 100")
def test_modeles_et_changement_de_modele(session):
assert "* qwen3.5:4b" in execute_command(session, "/models")
execute_command(session, "/model llama3.2:3b")
assert session.config.model == "llama3.2:3b"
execute_command(session, "/model qwen3.5") # -> qwen3.5:latest
assert session.config.model == "qwen3.5:latest"
assert "Modèle inconnu" in execute_command(session, "/model gpt-5")
assert session.config.model == "qwen3.5:latest"
def test_skill_chargee_dans_la_conversation(session):
assert "corriger-un-bug" in execute_command(session, "/skills")
execute_command(session, "/skill corriger-un-bug")
assert "Corriger un bug" in session.messages[-1]["content"]
assert "skill inconnue" in execute_command(session, "/skill inexistante")
def test_clear(session):
session.messages.append({"role": "user", "content": "x"})
execute_command(session, "/clear")
assert [m["role"] for m in session.messages] == ["system"]
def test_allow(session):
execute_command(session, "/allow python -m pytest")
assert session.ctx.allowed_commands == ["python -m pytest"]
assert execute_command(session, "/allow git status && del x").startswith("Refusé")
assert session.ctx.allowed_commands == ["python -m pytest"]
def test_init_cree_les_fichiers_du_projet(session, tmp_path):
from llamagent.config import read_toml
from llamagent.tools import available_skills
resultat = execute_command(session, "/init")
assert resultat.startswith("Créé :")
assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8").startswith("# Instructions du projet")
assert read_toml(tmp_path / ".llamagent" / "config.toml") == {} # tout est en commentaire
assert (tmp_path / ".llamagent" / "skills" / "LISEZMOI.txt").exists()
# Le LISEZMOI n'est pas une skill, et AGENTS.md est déjà dans le prompt système.
assert "LISEZMOI" not in available_skills(session.ctx.skills_dirs)
assert "## Instructions du projet (AGENTS.md)" in session.messages[0]["content"]
assert execute_command(session, "/init") == "Ce projet est déjà initialisé : rien à créer."
def test_init_ne_remplace_pas_un_fichier_existant(session, tmp_path):
(tmp_path / "AGENTS.md").write_text("Mes consignes.", encoding="utf-8")
resultat = execute_command(session, "/init")
assert " AGENTS.md" not in resultat # pas dans la liste des fichiers créés
assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "Mes consignes."
def test_init_refuse_par_l_utilisateur(session, tmp_path):
session.ctx.confirm = lambda question: False
assert execute_command(session, "/init") == "Rien n'a été créé."
assert not (tmp_path / "AGENTS.md").exists()
def test_sessions_reprise_et_suppression(session, tmp_path):
from llamagent.history import save_session
assert execute_command(session, "/sessions") == "Aucune conversation sauvegardée dans ce projet."
session.messages.append({"role": "user", "content": "Corrige le bug"})
save_session(session)
assert "Corrige le bug" in execute_command(session, "/sessions")
execute_command(session, "/clear")
assert execute_command(session, "/resume").startswith("Conversation reprise : Corrige le bug")
assert session.messages[-1]["content"] == "Corrige le bug"
assert "introuvable" in execute_command(session, "/resume 9")
session.ctx.confirm = lambda question: False
assert execute_command(session, "/delete 1") == "Rien n'a été supprimé."
session.ctx.confirm = lambda question: True
assert execute_command(session, "/delete 1").startswith("Conversation supprimée")
assert session.session_id is None
assert execute_command(session, "/sessions") == "Aucune conversation sauvegardée dans ce projet."
def test_context_et_compact(session, monkeypatch):
assert "total estimé" in execute_command(session, "/context")
assert execute_command(session, "/compact") == "Rien à compacter : la conversation est vide."
monkeypatch.setattr(llm, "chat", lambda config, messages, tools=None: {"role": "assistant", "content": "Résumé."})
session.messages.append({"role": "user", "content": "Bonjour"})
assert execute_command(session, "/compact").endswith("Résumé.")
# Le fichier sauvegardé contient la conversation compactée.
from llamagent.history import find_session
saved = find_session(session.config.workdir, "1")
assert "Résumé." in saved["messages"][0]["content"]
assert saved["title"] == "Bonjour" # le titre ne change pas après un compactage
def test_init_cree_un_gitignore_pour_les_sessions(session, tmp_path):
execute_command(session, "/init")
assert (tmp_path / ".llamagent" / ".gitignore").read_text(encoding="utf-8") == "sessions/\n""""Tests de la sauvegarde des conversations (history.py)."""
import json
import pytest
from llamagent.config import Config
from llamagent.history import delete_session, find_session, list_sessions, resume_session, save_session, sessions_dir
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
return create_session(config, confirm=lambda question: True)
def converse(session, *texts):
for text in texts:
session.messages.append({"role": "user", "content": text})
session.messages.append({"role": "assistant", "content": f"réponse à {text}"})
def test_conversation_vide_pas_sauvegardee(session, tmp_path):
save_session(session)
assert list_sessions(tmp_path) == []
def test_sauvegarde_et_liste(session, tmp_path):
converse(session, "Corrige le bug de calc.py\nmerci", "Et ajoute un test")
save_session(session)
sessions = list_sessions(tmp_path)
assert len(sessions) == 1
data = sessions[0]
assert data["id"] == session.session_id
assert data["title"] == "Corrige le bug de calc.py" # première ligne de la première demande
assert data["model"] == "qwen3.5:4b"
assert [m["role"] for m in data["messages"]] == ["user", "assistant", "user", "assistant"] # sans le prompt système
def test_meme_fichier_tant_que_la_conversation_continue(session, tmp_path):
converse(session, "Première demande")
save_session(session)
converse(session, "Deuxième demande")
save_session(session)
assert len(list(sessions_dir(tmp_path).glob("*.json"))) == 1
assert len(list_sessions(tmp_path)[0]["messages"]) == 4
def test_clear_commence_un_nouveau_fichier(session, tmp_path):
converse(session, "Première conversation")
save_session(session)
session.reset()
assert session.session_id is None
def test_reprise(session, tmp_path):
converse(session, "Corrige le bug")
save_session(session)
saved_id = session.session_id
session.reset()
(tmp_path / "AGENTS.md").write_text("Nouvelle consigne.", encoding="utf-8")
resume_session(session, find_session(tmp_path, "1"))
assert session.session_id == saved_id
assert [m["role"] for m in session.messages] == ["system", "user", "assistant"]
assert session.messages[0]["content"].endswith("Nouvelle consigne.") # prompt système relu
assert session.context_tokens > 0 # estimation en attendant Ollama
def test_recherche_par_numero_ou_identifiant(tmp_path):
folder = sessions_dir(tmp_path)
folder.mkdir(parents=True)
for name, updated in (("ancienne", "2026-10-01T10:00:00"), ("recente", "2026-10-25T10:00:00")):
data = {"id": name, "title": name, "updated": updated, "model": "m", "messages": []}
(folder / f"{name}.json").write_text(json.dumps(data), encoding="utf-8")
assert find_session(tmp_path, "1")["id"] == "recente"
assert find_session(tmp_path, "2")["id"] == "ancienne"
assert find_session(tmp_path, "ancienne")["id"] == "ancienne"
assert find_session(tmp_path, "3") is None
def test_fichier_abime_ignore(session, tmp_path):
converse(session, "Demande")
save_session(session)
(sessions_dir(tmp_path) / "abime.json").write_text("{pas du json", encoding="utf-8")
assert len(list_sessions(tmp_path)) == 1
def test_suppression(session, tmp_path):
converse(session, "Demande")
save_session(session)
delete_session(find_session(tmp_path, "1"))
assert list_sessions(tmp_path) == []"""Tests de la mesure et du compactage du contexte (context.py)."""
import pytest
from llamagent import llm
from llamagent.config import Config
from llamagent.context import compact, context_report, estimate_tokens, needs_compaction, transcript
from llamagent.session import create_session
@pytest.fixture
def session(tmp_path):
config = Config(ollama_url="http://ollama", model="qwen3.5:4b", workdir=tmp_path)
session = create_session(config, confirm=lambda question: True)
session.messages += [
{"role": "user", "content": "Corrige le bug de calc.py"},
{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "calc.py"}}}]},
{"role": "tool", "tool_name": "read_file", "content": "x" * 5000},
{"role": "assistant", "content": "C'est corrigé."},
]
return session
def test_estimation():
assert estimate_tokens("a" * 400) == 100
def test_rapport_par_categorie(session):
rapport = context_report(session)
for categorie in ("prompt système", "schémas des outils", "vos messages", "réponses du modèle", "résultats d'outils", "total estimé"):
assert categorie in rapport
ligne = next(l for l in rapport.splitlines() if l.startswith("résultats d'outils"))
assert ligne.endswith("≈ 1250 tokens") # 5000 caractères / 4
def test_transcription_raccourcit_les_resultats(session):
texte = transcript(session.messages)
assert "UTILISATEUR : Corrige le bug de calc.py" in texte
assert 'AGENT appelle read_file {"path": "calc.py"}' in texte
assert "RÉSULTAT de read_file :" in texte
assert len(texte) < 1000 # le résultat de 5000 caractères est tronqué
assert "prompt" not in texte.lower() # le prompt système n'est pas résumé
def test_compactage(monkeypatch, session):
envoyes = []
def faux_chat(config, messages, tools=None):
envoyes.append((messages, tools))
return {"role": "assistant", "content": " calc.py : add corrigée (a + b). "}
monkeypatch.setattr(llm, "chat", faux_chat)
system_prompt = session.messages[0]
resume = compact(session)
assert resume == "calc.py : add corrigée (a + b)."
messages, tools = envoyes[0]
assert tools is None # le résumé est demandé sans outils
assert "Tu résumes une conversation" in messages[0]["content"] # prompts/COMPACT.md
assert "UTILISATEUR : Corrige le bug de calc.py" in messages[1]["content"]
assert session.messages[0] is system_prompt
assert [m["role"] for m in session.messages] == ["system", "user", "assistant"]
assert "calc.py : add corrigée" in session.messages[1]["content"]
assert 0 < session.context_tokens < 2000
assert session.context_estimated
assert "(estimation)" in context_report(session)
def test_seuil_de_compactage(session):
session.context_tokens = 6000
assert not needs_compaction(session) # 6000 / 8192 = 73 %
session.context_tokens = 6600
assert needs_compaction(session) # 80 %"""Tests de l'interface (ui.py)."""
from collections import namedtuple
import pytest
from llamagent import ui
Size = namedtuple("Size", "columns lines")
def plain(text):
return ui.ANSI_CODE.sub("", text)
@pytest.fixture
def couleurs(monkeypatch):
"""Comme dans un vrai terminal : couleurs et caractères Unicode."""
monkeypatch.setattr(ui, "STYLE", True)
monkeypatch.setattr(ui, "UNICODE", True)
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(100, 40))
@pytest.fixture
def texte_simple(monkeypatch):
"""Comme une sortie redirigée vers un fichier : ni couleurs, ni Unicode."""
monkeypatch.setattr(ui, "STYLE", False)
monkeypatch.setattr(ui, "UNICODE", False)
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(100, 40))
def test_style_et_longueur_visible(couleurs):
texte = ui.style("bonjour", ui.BOLD, ui.TEAL)
assert texte == "\033[1;38;5;38mbonjour\033[0m"
assert ui.visible_len(texte) == 7
def test_mascotte(couleurs):
lignes = [plain(ligne) for ligne in ui.mascot()]
assert lignes == ui.MASCOT
assert ui.ORANGE in ui.mascot()[0] # l'antenne est orange
def test_banniere(couleurs):
banniere = plain(ui.banner([("modèle", "qwen3.5:4b"), ("dossier", "/home/nils/projets/calc")]))
assert "│o o│ un agent de code minimal autour d’Ollama" in banniere
assert "┌─┴─┐ llamagent" in banniere
assert " modèle qwen3.5:4b" in banniere
def test_banniere_raccourcit_les_chemins_longs(couleurs, monkeypatch):
monkeypatch.setattr(ui.shutil, "get_terminal_size", lambda fallback=None: Size(40, 40))
banniere = plain(ui.banner([("dossier", "/home/nils/" + "x" * 100 + "/calc")]))
ligne = banniere.splitlines()[-1]
assert ligne.endswith("/calc") and "…" in ligne
def test_sans_couleurs_ni_unicode(texte_simple):
banniere = ui.banner([("modèle", "qwen3.5:4b")])
assert "\033" not in banniere
assert "|o o|" in banniere # la mascotte en ASCII
assert ui.render_markdown("**gras** et `code`") == "**gras** et `code`" # Markdown laissé tel quel
def test_les_acteurs_sont_alignes(couleurs):
lignes = [plain(ui.role_line(nom, "texte")) for nom in ("vous", "modèle", "harness", "outil")]
assert {ligne.index("texte") for ligne in lignes} == {len(ui.INDENT)}
def test_le_modele_demande(couleurs):
assert plain(ui.model_asks("edit_file", {"path": "calc.py", "old_text": "a", "new_text": "b"})) == " modèle demande edit_file(calc.py, …)"
assert plain(ui.model_asks("list_files", {})) == " modèle demande list_files()"
def test_resultat_de_l_outil(couleurs):
assert ui.RED in ui.tool_result("Refusé : chemin refusé")
assert ui.RED not in ui.tool_result("Fichier calc.py modifié.")
assert plain(ui.tool_result("a\nb\nc", truncated=True)) == " outil a (+2 lignes) [tronqué]"
def test_traits_de_separation(couleurs):
assert plain(ui.tour_header(2)).strip().startswith("── tour 2 ──")
assert "réponse · 3 appels au modèle" in plain(ui.answer_header(3))
assert "1 appel au modèle" in plain(ui.answer_header(1))
def test_markdown(couleurs):
lignes = ui.render_markdown("# Titre\n- **gras** et `code`\n```\nprint(1)\n```").splitlines()
assert plain(lignes[0]) == "Titre"
assert plain(lignes[1]) == "► gras et code"
assert plain(lignes[2]) == "│ print(1)" # les ``` ne sont pas affichés
assert len(lignes) == 3
def test_jauge_de_contexte(couleurs):
assert "█████░░░░░" in ui.context_gauge("qwen3.5:4b", 4096, 8192, False)
assert ui.YELLOW in ui.context_gauge("qwen3.5:4b", 7000, 8192, False)
plein = ui.context_gauge("qwen3.5:4b", 9000, 8192, False)
assert ui.RED in plein and "plein" in plein
assert "≈882 / 2048" in plain(ui.context_gauge("qwen3.5:4b", 882, 2048, True))
def test_confirmation_avec_diff_en_couleurs(couleurs, monkeypatch, capsys):
questions = []
monkeypatch.setattr("builtins.input", lambda question: questions.append(question) or "o")
assert ui.confirm("--- a.py (avant)\n+++ a.py (après)\n-x = 1\n+x = 2\nÉcrire a.py ?") is True
sortie = capsys.readouterr().out
assert ui.INDENT + ui.style("-x = 1", ui.RED) in sortie # aligné sous le texte du harness
assert ui.style("+x = 2", ui.GREEN) in sortie
assert "Écrire a.py ?" in questions[0] and "[o/N]" in questions[0]
def test_animation_sans_terminal(texte_simple):
with ui.thinking():
pass # ne fait rien, et surtout ne bloque pas"""Tests de la ligne de commande (cli.py)."""
from importlib.metadata import version
import pytest
from llamagent.cli import PACKAGE_NAME, parse_args
def test_version(capsys):
with pytest.raises(SystemExit):
parse_args(["--version"])
assert capsys.readouterr().out.strip() == f"llamagent {version(PACKAGE_NAME)}"
def test_options():
args = parse_args(["-m", "qwen3.5:9b", "-t", "0.5", "-c", "Corrige", "le", "bug"])
assert (args.model, args.temperature, args.resume) == ("qwen3.5:9b", 0.5, True)
assert args.demande == ["Corrige", "le", "bug"]
def test_prompts_et_skills_dans_le_paquet():
from llamagent.config import PACKAGE_DIR, PROMPTS_DIR, SKILLS_DIR
assert PROMPTS_DIR.parent == SKILLS_DIR.parent == PACKAGE_DIR # installés avec le code
assert (PROMPTS_DIR / "AGENT.md").is_file() and (PROMPTS_DIR / "COMPACT.md").is_file()# Intégration continue (étape 18) : à chaque push sur main et à chaque pull
# request, les tests tournent sous Linux, Windows et macOS, avec Python 3.11
# à 3.13. Puis le paquet est construit, vérifié et installé comme le ferait
# un utilisateur.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
tests:
name: Tests (${{ matrix.os }}, Python ${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # voir tous les systèmes en échec, pas seulement le premier
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python }}
- name: Installer les dépendances (versions exactes de uv.lock)
run: uv sync --locked
- name: Tests
run: uv run pytest -q
package:
name: Paquet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- name: Construire
run: uv build
- name: Vérifier le contenu
run: python3 scripts/check_package.py
- name: Installer le paquet construit et le lancer
run: |
uv tool install dist/*.whl
LLAMAGENT="$(uv tool dir --bin)/llamagent"
"$LLAMAGENT" --version
mkdir "$RUNNER_TEMP/projet" && cd "$RUNNER_TEMP/projet"
echo /quit | "$LLAMAGENT"# Publication (étape 18) : quand une étiquette de version (v0.1.0...) est
# poussée, le paquet est testé, construit et vérifié, publié sur TestPyPI,
# puis sur PyPI après validation manuelle (environnement « pypi » protégé).
# Aucun mot de passe ni jeton : PyPI fait confiance à ce workflow précis
# (Trusted Publishing), qui prouve son identité avec un jeton OIDC de GitHub.
name: Publication
on:
push:
tags: ["v*"]
jobs:
build:
name: Tester et construire
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- name: Tests
run: |
uv sync --locked
uv run pytest -q
- name: Construire
run: uv build
- name: Vérifier le paquet et la version
run: python3 scripts/check_package.py "$GITHUB_REF_NAME"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
testpypi:
name: Publier sur TestPyPI
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/llamagent
permissions:
id-token: write # nécessaire au Trusted Publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
pypi:
name: Publier sur PyPI
needs: testpypi
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/llamagent
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1"""Vérifie le paquet construit avant de le publier (étape 18).
python scripts/check_package.py # le contenu du paquet (dist/)
python scripts/check_package.py v0.1.0 # et l'étiquette correspond à la version
"""
import re
import sys
import tomllib
import zipfile
from pathlib import Path
# Fichiers sans lesquels llamagent ne démarre pas : s'ils manquent dans le
# paquet, il s'installe sans erreur mais plante au premier lancement.
REQUIRED = [
"llamagent/cli.py",
"llamagent/prompts/AGENT.md",
"llamagent/prompts/COMPACT.md",
"llamagent/skills/corriger-un-bug.md",
"llamagent/skills/ecrire-tests-pytest.md",
]
def main() -> None:
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
version = project["version"]
# Dans le nom du fichier .whl, le nom du paquet est normalisé : « - » devient « _ ».
name = re.sub(r"[-_.]+", "_", project["name"]).lower()
if len(sys.argv) > 1 and sys.argv[1] != f"v{version}":
sys.exit(f"L'étiquette {sys.argv[1]} ne correspond pas à la version v{version} de pyproject.toml.")
wheels = list(Path("dist").glob(f"{name}-{version}-*.whl"))
if len(wheels) != 1:
sys.exit(f"Un seul paquet {name}-{version} attendu dans dist/, trouvé : {[w.name for w in wheels]}")
names = zipfile.ZipFile(wheels[0]).namelist()
missing = [name for name in REQUIRED if name not in names]
if missing:
sys.exit(f"Fichiers absents du paquet : {', '.join(missing)}")
print(f"{wheels[0].name} : {len(names)} fichiers, prompts et skills présents.")
if __name__ == "__main__":
main()# llamagent
Un agent de code minimal en Python, écrit à la main pour comprendre comment fonctionne un agent de code : boucle agentique, harness, outils, prompt système, skills et garde-fous.
Il pilote un LLM local servi par [Ollama](https://ollama.com) (par défaut `qwen3.5:4b`) via l'API native `/api/chat`. Aucun framework d'agent : une seule dépendance, `requests`.

📖 Tutoriel complet, étape par étape : [nils-malmberg.fr/project8.html](https://nils-malmberg.fr/project8.html)
## Installation
Prérequis : un serveur [Ollama](https://ollama.com) avec un modèle qui gère les outils (`ollama pull qwen3.5:4b`).
```bash
uv tool install llamagent # ou : pipx install llamagent
```
Si Ollama ne tourne pas sur la même machine, indiquez son adresse dans `~/.llamagent/config.toml` (sous Windows : `C:\Users\<vous>\.llamagent\config.toml`) :
```toml
url = "http://192.168.1.76:11434"
```
## Utilisation
```bash
cd chemin/vers/mon-projet
llamagent # mode conversation
llamagent "Trouve le bug dans @calc.py" # une seule demande, puis on quitte
llamagent -c # reprendre la dernière conversation
llamagent -m qwen3.5:9b -t 0.5 # choisir le modèle et la température
llamagent --help
```
Le dossier depuis lequel on lance `llamagent` devient le dossier de travail : les outils de fichiers ne peuvent ni lire ni écrire ailleurs.
Chaque tour de la boucle est affiché avec ses trois acteurs : le **modèle** (ce qu'il demande), le **harness** (ce que les garde-fous en font) et l'**outil** (ce qu'il renvoie).
### Commandes
| Commande | Rôle |
|---|---|
| `/help` | liste des commandes |
| `/init` | préparer le projet : crée `AGENTS.md` et le dossier `.llamagent/` |
| `/config` | réglages actuels |
| `/models`, `/model qwen3.5:9b` | modèles installés, changer de modèle |
| `/temp 0.5`, `/ctx 16384` | changer la température, la taille du contexte |
| `/skills`, `/skill corriger-un-bug` | skills disponibles, charger une skill |
| `/context`, `/compact` | ce qui occupe le contexte, le compacter (automatique à 80 %) |
| `/sessions`, `/resume 2`, `/delete 2` | conversations sauvegardées |
| `/allow python -m pytest` | lancer une commande sans confirmation pour la session |
| `/clear`, `/quit` | effacer la conversation, quitter |
`@chemin` dans une demande joint le contenu du fichier.
## Configuration
Les réglages viennent de cinq sources ; chacune remplace la précédente :
1. les valeurs par défaut ;
2. le fichier global `~/.llamagent/config.toml` ;
3. le fichier du projet `.llamagent/config.toml` ;
4. les variables d'environnement `OLLAMA_URL` et `OLLAMA_MODEL` ;
5. les options de la ligne de commande.
```toml
url = "http://localhost:11434"
model = "qwen3.5:4b"
temperature = 0.2
num_ctx = 8192
allowed_commands = ["python -m pytest", "git status", "git diff"]
```
Autres variables : `LLAMAGENT_WORKDIR` (dossier de travail) et `NO_COLOR` (affichage sans couleurs).
## Personnaliser un projet
```
mon-projet/
├── AGENTS.md # consignes du projet, ajoutées au prompt système
└── .llamagent/
├── .gitignore # exclut sessions/ de git
├── config.toml # réglages du projet
├── sessions/ # conversations sauvegardées
└── skills/ # skills du projet (remplacent les globales de même nom)
```
`/init` crée ces fichiers. Une skill est un fichier Markdown avec un en-tête `description:` ; le modèle la charge avec l'outil `load_skill` quand la demande correspond.
```markdown
---
description: Écrire des tests pytest pour un module Python.
---
# Écrire des tests pytest
1. Lis le module à tester avec read_file.
2. ...
```
## Limites
- Seuls les outils de fichiers sont confinés au dossier de travail. Une commande lancée par `run_command` peut écrire ailleurs : la protection est la confirmation `[o/N]`. Les commandes autorisées sont un confort, pas une barrière de sécurité.
- Avec un modèle 4B et 8192 tokens, le contexte se remplit vite : `/compact` le résume.
- Un petit modèle peut inventer un outil, se tromper de chemin ou répéter un appel. Les erreurs lui sont renvoyées pour qu'il se corrige, mais il n'y arrive pas toujours.
## Développement
```bash
git clone https://github.com/nils-malmberg/llamagent.git
cd llamagent
uv sync
uv run pytest -q
uv tool install -e . # la commande llamagent utilise ce dossier
```
Chaque push lance les tests sous Windows, macOS et Linux (Python 3.11 à 3.13). Pour publier une version : mettre à jour `version` dans `pyproject.toml`, puis créer et pousser l'étiquette correspondante (`git tag v0.1.0 && git push origin v0.1.0`) ; le paquet est publié sur TestPyPI, puis sur PyPI après validation.
## Licence
MIT, voir [LICENSE](https://github.com/nils-malmberg/llamagent/blob/main/LICENSE).Glossaire
- LLM (Large Language Model) : modèle qui, à partir d'un texte, prédit la suite token par token. Il ne fait que produire du texte : il ne lit aucun fichier et n'exécute rien lui-même. Ici : qwen3.5:4b servi par Ollama.
- Contexte : tout ce que le modèle « voit » lors d'un appel (prompt système, historique, définitions des outils, résultats des outils). Sa taille est limitée (
num_ctx= 8192 tokens ici) et c'est la seule mémoire du modèle : entre deux appels, il ne se souvient de rien. - Prompt système : premier message de la conversation (rôle
system) qui fixe le rôle, les règles et le style de l'agent. Il est renvoyé au modèle à chaque appel. - Tool (outil) : fonction Python que le harness met à disposition du modèle (lire un fichier, lancer une commande…). Le modèle n'en connaît que le nom, la description et le schéma JSON des paramètres.
- Tool call : demande structurée émise par le modèle, du type « appelle
read_fileavecpath = "main.py"». Le modèle n'exécute rien : c'est le harness qui lit la demande, exécute la fonction et renvoie le résultat. - Boucle agentique : appeler le modèle, exécuter les tool calls qu'il demande, lui renvoyer les résultats, et recommencer jusqu'à ce qu'il réponde sans demander d'outil.
- Harness : tout le code autour du modèle (boucle, registre d'outils, garde-fous, chargement du prompt et des skills, interface). Un même modèle peut faire un bon ou un mauvais agent selon son harness.
- Skill : fichier Markdown d'instructions spécialisées (par exemple « écrire des tests pytest ») que l'agent charge à la demande via un outil, au lieu de tout mettre dans le prompt système. On économise ainsi du contexte.
- MCP (Model Context Protocol) : protocole ouvert qui standardise la façon d'exposer des outils et des données à un agent via un serveur. llamagent n'en utilise pas (ses outils sont de simples fonctions Python locales), mais le principe est le même : un nom, une description, un schéma, un résultat.
- Garde-fous : limites imposées par le harness, et non par le modèle : confirmation avant d'écrire ou d'exécuter, dossier de travail restreint, nombre maximal de tours, timeout, troncature des sorties trop longues.