LangueFR
Français
English
Retour aux projets

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.

Interface de llamagent dans un terminal : la mascotte, puis chaque tour de la boucle avec le modèle, le harness et l'outil

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.

Utilisateur (terminal) HARNESS — tout le code autour du modèle cli.py entrée / sortie prompts/ skills/ consignes au modèle tools.py registre d'outils guards.py garde-fous agent.py — boucle agentique tant que le modèle demande des outils : exécuter l'outil → renvoyer le résultat → rappeler le modèle sinon : afficher la réponse finale messages + tools réponse ou tool_calls LLM — Ollama /api/chat llm.py → qwen3.5:4b sur le NAS (RTX 3050, 6 Go)
Les couches de llamagent : le harness entoure la boucle, seule la boucle parle au LLM.
Message utilisateur → messages[] Appel LLM (messages + tools) tool_calls ? oui Exécuter chaque outil (après les garde-fous) Ajouter le résultat {"role": "tool", ...} non Réponse finale affichée message suivant Garde-fou : MAX_TOURS limite le nombre d'allers-retours avec le LLM.
La boucle agentique : le modèle demande, le harness exécute, jusqu'à une réponse sans outil.

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éfaut http://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éfaut qwen3.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.
macOS (zsh / bash)
export OLLAMA_URL="http://192.168.1.76:11434"
export OLLAMA_MODEL="qwen3.5:4b"
Windows (PowerShell)
$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}
}

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.

0 Créer le projet (uv, hatchling, configuration)

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.

Création du projet
uv init --package --build-backend hatch --name llamagent llamagent
cd llamagent
uv add requests
uv run llamagent
pyproject.toml
[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"
src/llamagent/config.py (version de l'étape 0, fichier complet)
"""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)
src/llamagent/cli.py (version de l'étape 0)
"""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/chat

En 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/chat

Sous 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 init fixe requires-python sur la version de Python présente sur la machine (ici >=3.13). Je l'ai ramené à >=3.11 et j'ai épinglé .python-version sur 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 échouer uv 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ù le rstrip("/").
1 Premier appel à /api/chat

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 /v1 compatible OpenAI pour voir le vrai format d'Ollama : champ think, bloc options, arguments d'outils en objet JSON.
steps/step1_hello.py
"""É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.
2 Chat avec mémoire

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.

steps/step2_chat.py
"""É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.
  • /messages affiche exactement ce qui est envoyé au modèle.
  • /oublie puis « 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.
3 Premier outil (sans l'exécuter)

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.

steps/step3_tool.py
"""É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 champ tool_calls : à noter si ça arrive, c'est typique des petits modèles.
4 La boucle agentique

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.

steps/step4_loop.py
"""É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_tool renvoie 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.
5 Registre d'outils et passage dans src/

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 TOOLS qui associe un nom à (fonction, schéma). La boucle ne connaît que tool_schemas() et execute_tool(). Ajouter un outil = une fonction + un schéma + une ligne dans le dictionnaire.
  • Contexte des outils : ToolContext regroupe 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_turn reçoit la fonction chat en paramètre. En vrai, c'est llm.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.

src/llamagent/config.py (version des étapes 5 à 8)
"""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)
src/llamagent/llm.py
"""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"]
À faire aussi : 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.
src/llamagent/tools.py (version de l'étape 5, fichier complet)
"""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.

src/llamagent/agent.py (version de l'étape 5, fichier complet)
"""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/test_agent.py (version de l'étape 5, fichier complet)
"""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 :

try_step5.py
"""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

Observé lors des premiers essais réels :
  • L'agent a répondu « le dossier de travail est vide ». Pour un dossier qui n'existe pas, os.walk ne signale aucune erreur : list_files répondait « (aucun fichier) » et le modèle l'a cru. list_files renvoie maintenant une erreur explicite, et load_config refuse au démarrage un dossier de travail inexistant.
  • Pour lister les fichiers, qwen3.5:4b a préféré run_command("ls -la") à l'outil list_files. Après un refus, il a proposé ls -la && cat pyproject.toml. Ces commandes Unix n'existent pas dans cmd.exe sous 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_command avec NotADirectoryError: [WinError 267] : c'est le cwd de subprocess.run qui est invalide. À l'étape 5, execute_tool ne rattrape pas encore les exceptions : l'agent plante (corrigé à l'étape 8).
  • try_step5.py affiche 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_file sur un dossier, ou list_files sur un fichier : l'erreur lui est renvoyée.
  • write_file avec 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_command passe par cmd.exe, où ls et cat n'existent pas. Le prompt système de l'étape 7 indique le shell au modèle.
  • Un programme qui attend une saisie (input(), python sans argument) bloquerait le terminal : stdin=subprocess.DEVNULL lui envoie « fin de fichier ».
6 Garde-fous

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] avant write_file (avec le diff) et run_command. Par défaut, Entrée = non.
  • Dossier de travail : safe_path refuse .. 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.
src/llamagent/guards.py
"""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:]}"
src/llamagent/config.py — limites et dossier de travail (déjà en place depuis l'étape 5)
# 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 :

src/llamagent/agent.py — imports
from llamagent.config import MAX_OUTPUT_CHARS, MAX_TOURS
from llamagent.guards import truncate
src/llamagent/agent.py — dans run_turn
        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/test_guards.py
"""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 attendu

Ce 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.
7 Prompt système et skills

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:).

prompts/AGENT.md
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}
skills/corriger-un-bug.md
---
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.
skills/ecrire-tests-pytest.md
---
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 :

src/llamagent/tools.py — skills
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) :

src/llamagent/tools.py — dans TOOLS
    "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 :

src/llamagent/agent.py — imports
"""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_schemas
src/llamagent/agent.py
def 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 :

src/llamagent/cli.py
"""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_skill renvoie 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/ et skills/ sont trouvés à partir de l'emplacement de config.py : ça marche avec l'installation éditable de uv sync, pas avec un wheel installé ailleurs.
8 Robustesse et démo

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.
src/llamagent/tools.py — execute_tool (version finale)
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}"
src/llamagent/agent.py — affichage
# 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))
src/llamagent/agent.py — run_turn (version finale)
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

demo/stats.py
"""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.5

Les 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_file avec seulement la fonction modifiée. Le diff le montre avant d'écrire : refuser avec N.
  • Le modèle « corrige » le test ou l'assert au lieu du bug.
  • Sous Windows, accents mal affichés dans la sortie d'une commande : PYTHONIOENCODING=utf-8 est 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.

Comment suivre cette partie. Ces fonctionnalités touchent les mêmes fichiers (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/).
9 Lancer llamagent dans n'importe quel projet

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_files et edit_file passent par safe_path et 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.
Installation (une seule fois)
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
Structure d'un projet
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.md
Ces fichiers n'existent pas d'avance. AGENTS.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
AGENTS.md (exemple)
# 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/.
src/llamagent/config.py — dossier et fichier du projet
# 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"
src/llamagent/tools.py — plusieurs dossiers de skills
@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()}
src/llamagent/session.py — skills et consignes du projet
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)
src/llamagent/agent.py — AGENTS.md à la fin du 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

Ce 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/ et skills/ 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 .env du projet reste lisible par read_file. Et run_command peut toujours écrire ailleurs : voir l'étape 14.
  • Un AGENTS.md long 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 /clear ou un redémarrage : le prompt système est construit au début de la conversation.
10 Session et commandes /…

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. Config est figée (frozen=True) : pour changer un réglage, dataclasses.replace() crée une nouvelle configuration, et la conversation est conservée.
  • Session.chat lit self.config à chaque appel : un /temp s'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.
  • /models interroge GET /api/tags : la liste des modèles installés sur le serveur Ollama.
  • /init prépare le projet courant : il crée AGENTS.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 inclure AGENTS.md. Le fichier d'explication est un .txt : une skill d'exemple en .md serait listée dans le prompt système, et le modèle pourrait la charger pour rien.
src/llamagent/llm.py
"""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"])
src/llamagent/session.py — la session
@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)
src/llamagent/commands.py
"""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}"
src/llamagent/cli.py — main
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/test_commands.py
"""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_ctx oblige 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.5 désigne qwen3.5:latest pour Ollama, pas qwen3.5:4b.
11 Taguer des fichiers avec @

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.

src/llamagent/session.py — fichiers tagués
# « @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)
src/llamagent/session.py — la demande est complétée avant l'envoi
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)
src/llamagent/cli.py — handle_request
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/test_session.py
"""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.
12 Options et fichiers config.toml

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_URL et OLLAMA_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.

Fichier global (exemple)
# ~/.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
Fichier du projet (exemple)
# mon-projet/.llamagent/config.toml
temperature = 0.1
allowed_commands = ["python -m pytest", "git status", "git diff"]
src/llamagent/config.py (version finale)
"""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}")
src/llamagent/cli.py — parse_args
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/test_config.py
"""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_MODEL restée définie dans le terminal l'emporte sur les fichiers : vérifier avec /config, l'effacer avec Remove-Item Env:OLLAMA_MODEL.
  • Les titres de l'aide (usage, options) sont en anglais : ils viennent d'argparse, pas de llamagent.
13 Compteur de contexte et edit_file

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 ».

src/llamagent/llm.py — la réponse complète, avec les compteurs
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()
src/llamagent/session.py — le compteur est mis à jour à chaque appel
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"]
src/llamagent/cli.py — context_status
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)
src/llamagent/tools.py — edit_file
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é'}."
src/llamagent/tools.py — dans TOOLS, à la place de write_file
    "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"],
        ),
    ),
prompts/AGENT.md (version finale)
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}
skills/corriger-un-bug.md (version finale)
---
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

tests/test_tools.py (extrait)
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_text doit ê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.
  • /clear remet le compteur à zéro et relit le prompt système.
14 Commandes autorisées sans confirmation

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.

C'est un confort, pas une barrière de sécurité. N'autoriser que des commandes sans effet en dehors du projet : jamais python seul (python -c peut tout faire), ni git seul (git push), ni del, rm ou pip install.
src/llamagent/guards.py — commandes autorisées
# 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)
src/llamagent/tools.py — run_command
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}"
    )
src/llamagent/commands.py — /allow
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

tests/test_guards.py (extrait)
@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

Piè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 /allow sont oubliées en quittant ; celles de config.toml valent pour toutes les sessions du projet.
15 Sauvegarder et reprendre les conversations

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.md et 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).
  • /sessions liste les conversations (1 = la plus récente), /resume en reprend une, /delete en supprime une après confirmation, et llamagent -c reprend la dernière au démarrage. /clear commence une nouvelle conversation : l'ancienne reste sauvegardée.
  • /delete supprime 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.
src/llamagent/history.py
"""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)
src/llamagent/session.py — le titre de la sauvegarde
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 ""
src/llamagent/commands.py — /sessions, /resume, /delete
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', '')}"
src/llamagent/cli.py — sauvegarde après chaque demande
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/test_history.py
"""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. /init ajoute un .llamagent/.gitignore qui exclut sessions/ 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.
16 Mesurer et compacter le contexte

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.

  • /compact compacte à 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.
src/llamagent/config.py — seuil de compactage
# Compactage automatique quand le contexte est rempli à 80 % (étape 16).
COMPACT_THRESHOLD = 0.8
prompts/COMPACT.md
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.
src/llamagent/context.py
"""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 summary
src/llamagent/commands.py — /context, /compact
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}"

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/test_context.py
"""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 /compact soi-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.
17 Une interface plus jolie

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(), dans agent.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_COLOR est 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.
Interface de llamagent : la mascotte, puis chaque tour de la boucle avec le modèle, le harness et l'outil
src/llamagent/ui.py
"""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())
src/llamagent/agent.py — la boucle, affichée tour par tour
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."
src/llamagent/session.py — l'animation entoure l'appel au modèle
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"]
src/llamagent/cli.py (version finale)
"""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 :

Animation d'attente dans la colonne du modèle

Tests

tests/test_ui.py
"""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

Piè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 utiliser NO_COLOR.
  • Le Terminal de macOS ne gère que 256 couleurs : d'où la palette 38;5;N plutô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.
18 Publier sur PyPI : CI/CD

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 --locked installe exactement les versions de uv.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/ et skills/ passent dans src/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, et llamagent --version affiche 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.

pyproject.toml (version finale)
[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",
]
src/llamagent/config.py — adresse par défaut et dossiers du paquet
# 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"
.github/workflows/ci.yml
# 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"
.github/workflows/release.yml
# 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
scripts/check_package.py
"""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 :

src/llamagent/tools.py — arrêter la commande et ses sous-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}"
    )
tests/test_tools.py (extrait)
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)

  1. 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)
  1. La même chose sur test.pypi.org, avec l'environnement testpypi.
  2. Dans le dépôt GitHub : Settings → Environments, créer testpypi, puis pypi avec Required reviewers (toi) et, dans Deployment branches and tags, seulement les étiquettes v*.
  3. Pousser .github/workflows/, scripts/check_package.py et les autres changements sur main : 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

  vous

Sur 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/test_cli.py
"""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 ., avec o = 0 et i = l = 1. mini-agent et le projet existant miniagent donnent 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 dans src/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/ et skills/ n'étaient pas dans le paquet. Seul un test sur le paquet construit, installé ailleurs, le révèle : d'où le job « Paquet » et check_package.py.
  • Rencontré : le timeout de run_command et les sous-processus, ci-dessus. La branche Windows de kill_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 llamagent avant toi, elle devient invalide. Publier la première version sans trop tarder.
  • uv sync --locked échoue si uv.lock n'est plus à jour : relancer uv lock après chaque modification de pyproject.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. Mettre url = "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.

A pyproject.toml
pyproject.toml
[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",
]
.python-version
3.11
LICENSE
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.
B src/llamagent/config.py
src/llamagent/config.py
"""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}")
C src/llamagent/llm.py
src/llamagent/llm.py
"""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"])
D src/llamagent/guards.py
src/llamagent/guards.py
"""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)
E src/llamagent/tools.py
src/llamagent/tools.py
"""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}"
F src/llamagent/agent.py
src/llamagent/agent.py
"""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."
G src/llamagent/session.py
src/llamagent/session.py
"""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)
H src/llamagent/history.py
src/llamagent/history.py
"""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)
I src/llamagent/context.py
src/llamagent/context.py
"""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
J src/llamagent/commands.py
src/llamagent/commands.py
"""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}"
K src/llamagent/ui.py
src/llamagent/ui.py
"""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())
L src/llamagent/cli.py
src/llamagent/cli.py
"""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()
src/llamagent/__init__.py
"""llamagent : un agent de code minimal autour d'Ollama."""
M prompts/ + skills/
src/llamagent/prompts/AGENT.md
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}
src/llamagent/prompts/COMPACT.md
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.
src/llamagent/skills/corriger-un-bug.md
---
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.
src/llamagent/skills/ecrire-tests-pytest.md
---
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.
N demo/stats.py
demo/stats.py
"""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")
O tests/test_config.py
tests/test_config.py
"""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()
P tests/test_guards.py
tests/test_guards.py
"""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
Q tests/test_tools.py
tests/test_tools.py
"""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")
R tests/test_agent.py
tests/test_agent.py
"""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)
S tests/test_session.py
tests/test_session.py
"""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"
T tests/test_commands.py
tests/test_commands.py
"""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"
U tests/test_history.py
tests/test_history.py
"""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) == []
V tests/test_context.py
tests/test_context.py
"""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 %
W tests/test_ui.py
tests/test_ui.py
"""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
X tests/test_cli.py
tests/test_cli.py
"""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()
Y .github/workflows/
.github/workflows/ci.yml
# 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"
.github/workflows/release.yml
# 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
scripts/check_package.py
"""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()
Z README.md
README.md
# 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`.

![Interface de llamagent : la mascotte, puis chaque tour de la boucle avec le modèle, le harness et l'outil](https://nils-malmberg.fr/media/projets/project8/interface.png)

📖 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_file avec path = "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.
Retour Lien vers GitHub Voir sur PyPI

PythonLLMOllama IA agentique2026en cours