Loom-IA
Loom-IA est un moteur d'agents IA écrit en Python asynchrone. Vous décrivez un agent : le modèle qui pilote, les outils qu'il peut appeler, les règles qu'il doit respecter. Loom-IA le fait tourner et garde la trace de tout ce qui se passe. Le même moteur s'utilise comme librairie Python, comme API REST ou comme serveur MCP.
Il a été conçu pour des agents qui travaillent pour de vrai, chez des artisans et dans des PME : relancer un devis, répondre à un client, préparer un document. Ce sont des tâches où un montant inventé ou un e-mail envoyé deux fois ne se rattrapent pas avec des excuses.
pip install "loom-ia[anthropic,http,mcp]"
Sommaire
- Qu'est-ce que Loom-IA ?
- Loom-IA sur le terrain
- Guide d'installation
- Prise en main : votre premier agent
- Partie technique : les fonctions de Loom-IA
- Développer et tester
- Licence
Qu'est-ce que Loom-IA ?
Le problème
Faire tourner un agent dans un notebook prend un après-midi. Le confier à un plombier qui s'en servira tous les jours, c'est une autre affaire. L'agent ne doit pas inventer de montant ni coûter plus cher qu'il ne rapporte. Il doit demander la permission avant d'envoyer quoi que ce soit et survivre à un redémarrage du serveur. Et il faut pouvoir comprendre après coup pourquoi il a répondu ce qu'il a répondu.
Loom-IA prend en charge cette partie-là. Le métier, c'est-à-dire les outils, les prompts et les règles propres à votre activité, reste de votre côté.
Le principe
Un agent reçoit une demande : du texte, parfois une photo. Un premier modèle, l'orchestrateur, la lit et choisit entre répondre tout de suite et appeler des outils. Ces outils peuvent être des fonctions Python, des outils exposés par des serveurs MCP, d'autres modèles spécialisés dans une tâche, ou des sous-agents complets. Les résultats reviennent à l'orchestrateur, qui recommence jusqu'à pouvoir répondre.
Ce schéma est classique. Ce qui change, c'est la façon dont Loom-IA encadre la boucle, sur trois points.
Le journal fait foi. Chaque fait d'un run est écrit dans un journal d'événements au moment où il se produit : un message, un appel de modèle, un appel d'outil, une décision, un coût. L'état du run, l'historique de la conversation, les coûts et les traces se déduisent tous de ce journal. Un run peut donc reprendre après un plantage, rester des heures en pause en attendant une validation, ou être rejoué à l'identique une semaine plus tard pour comprendre ce qui s'est passé.
Le code décide, le modèle propose. Les budgets, les contrôles de sortie, les demandes d'approbation et le déclenchement d'un juge sont du code déterministe, branché à des points précis de la boucle. Un modèle ne peut pas choisir de sauter une validation. Il ne fait pas non plus les calculs : un outil s'en charge, et un contrôle vérifie ce que le modèle en a retenu.
Chaque tâche va au modèle qui lui convient. L'orchestrateur peut confier une tâche bien délimitée, comme rédiger un e-mail ou décrire une photo, à un modèle moins cher. Ce modèle ne reçoit que ce dont il a besoin, sans tout l'historique. Le modèle le plus cher sert à réfléchir, pas à recopier un devis.
Trois façons de s'en servir
| Accès | Pour qui | Ce qu'il apporte |
|---|---|---|
| Librairie Python | Votre application Python | Vos outils Python, le déroulé en direct, la validation humaine dans le code |
| API REST | Une appli mobile, un front, un autre service | Runs synchrones ou en arrière-plan, flux SSE, approbations, lecture des sessions et des traces |
| Serveur MCP | Claude Desktop, Claude Code, un autre agent | Chaque agent devient un outil MCP |
La commande loom complète le tout. Elle sert à lancer un run, le suivre, l'inspecter, le rejouer ou valider une configuration.
Les trois accès partagent la même configuration et le même moteur. Un même agent, interrogé par l'API Python, par REST ou par MCP, laisse la même suite d'événements dans le journal.
Ce que Loom-IA ne fait pas
Pas d'interface graphique. Loom-IA fournit en revanche de quoi en construire une : des traces structurées en arbre, une API de lecture et un schéma d'événements versionné.
Pas de logique métier. Elle reste dans vos outils, vos prompts et vos règles.
Pas de framework multi-agents. Les agents ne discutent pas entre eux. La délégation est hiérarchique : l'orchestrateur confie des tâches à des rôles et à des sous-agents, qui lui rendent un résultat.
Quelques mots de vocabulaire
| Terme | Sens |
|---|---|
| Agent | Une définition nommée : un orchestrateur, des outils, des rôles, des règles. Une instance en héberge plusieurs. |
| Orchestrateur | Le modèle qui pilote la boucle et choisit les outils. Dans la configuration, c'est le rôle main. |
| Rôle | Un modèle spécialisé, appelé par l'orchestrateur comme un outil, avec son propre prompt. |
| Sous-agent | Un agent complet appelé comme un outil, avec ses propres outils et sa propre boucle. |
| Outil | Ce que l'orchestrateur peut appeler : fonction Python, outil MCP, rôle, sous-agent. |
| Run | Une exécution d'un agent pour une demande. |
| Session | Une conversation : plusieurs runs qui partagent le même journal. |
| Journal | La suite ordonnée et immuable des événements d'une session. |
| Politique | Du code branché sur un point de la boucle, qui laisse passer, corrige, refuse ou arrête. |
| Juge | Un modèle qui note une sortie selon des critères écrits. |
| Client | Un espace isolé (configuration, secrets, journal, budgets) qui permet de servir plusieurs entreprises avec une seule instance. |
Loom-IA sur le terrain
Tous les cas qui suivent viennent des exemples du dépôt, dans examples/. La plupart tournent avec des modèles simulés, sans clé ni réseau. L'option --reel les relance avec de vrais modèles, dont les clés sont lues dans l'environnement.
Relancer un devis resté sans réponse
La Plomberie Dupont a envoyé début septembre un devis de 1 840 € à Mme Martin pour remplacer son chauffe-eau. Pas de nouvelles depuis. L'artisan écrit à son agent : « Relance Mme Martin pour le devis D-2026-042, sur un ton cordial. »
L'orchestrateur commence par appeler chercher_devis, un outil Python branché sur les données de l'entreprise. Il confie ensuite la rédaction au rôle rediger_relance, servi par un modèle plus léger. Ce rôle reçoit la demande de l'artisan mot pour mot et le devis, rien d'autre.
L'e-mail doit sortir en JSON, avec un objet et un corps. Un contrat vérifie ce format. Un juge, troisième modèle, contrôle ensuite qu'aucun montant, date ou délai n'a été inventé. S'il refuse, le rôle corrige sa copie.
Reste l'envoi. L'outil envoyer_email est déclaré à effet de bord et soumis à approbation : le run se met en pause et attend que l'artisan valide. Si le serveur redémarre entre-temps, ou si le process est tué en pleine exécution, le run reprend là où il s'était arrêté. La clé d'idempotence garantit que l'e-mail ne part qu'une fois. Le journal garde toute la chronologie : les appels, les coûts, la validation, qui l'a donnée et quand.
À voir dans examples/j3/ (contrats, juge, budget, modèle de secours) et examples/j4/ (conversation, approbation, reprise après un kill -9).
Un assistant qui lit l'heure, compte et regarde une photo
Dans examples/j2/, l'orchestrateur lit l'heure grâce à un serveur MCP et fait ses calculs avec un second. Quand on lui joint une photo, il en confie la description à un rôle « vision », qui n'est proposé au modèle que dans ce cas. Avant de répondre, il fait vérifier ses dates et ses calculs par un sous-agent qui a ses propres outils. Chaque appel, chez le parent comme chez l'enfant, se retrouve dans l'arbre du run.
Un même service pour plusieurs entreprises
La Plomberie Dupont et le Chauffage Martin utilisent le même agent de relance. Chacune a ses modèles, son budget, le jeton de son propre CRM et son propre journal. Chacune a aussi sa clé d'API, et c'est la clé seule qui décide au nom de qui une requête agit.
Avec un journal Postgres, une politique de sécurité au niveau des lignes empêche une requête de lire les données d'un autre client. Le journal peut aussi être chiffré avec une clé par client : effacer cette clé rend ses données illisibles, sauvegardes comprises.
En production, les runs passent par une file RabbitMQ et plusieurs workers : si l'un d'eux tombe, un autre reprend le run. Un webhook peut aussi ouvrir un run sans que l'appelant connaisse l'API de Loom-IA. C'est le cas d'un CRM qui signale un devis signé, ou d'un planificateur qui lance chaque matin la tournée des relances. À voir dans examples/j5/.
Un agent qui fabrique ses propres outils
Avec la source d'outils forge, l'agent écrit lui-même une petite fonction Python, accompagnée d'exemples. C'est le cas de total_ttc, qui calcule le HT, la TVA et le TTC d'un devis.
Ce code ne s'exécute jamais sur la machine hôte. Chaque exemple tourne dans une microVM Firecracker, et l'outil n'est accepté que si tous les exemples donnent le résultat attendu. Il rejoint alors le catalogue du client et devient, dès le run suivant, un outil comme les autres. À voir dans examples/j6/forge.py (Linux et KVM nécessaires).
Une mémoire qui ne retient que ce qu'on lui autorise
La mémoire long terme passe par un serveur MCP, loom-notes, branché comme n'importe quel autre. L'agent y cherche librement, mais chaque écriture attend l'accord de l'artisan, et chaque client a sa propre base. À voir dans examples/j6/memoire.py.
Vos agents dans Claude Desktop ou Claude Code
loom mcp publie chaque agent comme un outil MCP. Vous demandez à Claude Code de relancer le devis 042, et c'est votre agent qui fait le travail, avec ses règles et ses contrôles.
Quand un appel demande une approbation, Loom-IA pose la question à l'utilisateur si le client MCP sait le faire. Sinon, le run attend en pause qu'un humain tranche par l'API REST ou en ligne de commande. Une approbation ne passe jamais par un outil MCP : un modèle ne peut pas valider lui-même une action sensible.
Changer de modèle sans rien casser
Un nouveau modèle sort, moins cher. Avant de basculer, vous rejouez des runs réels avec lui :
loom replay <run_id> --mode variant --model main=NOUVEAU_MODELE
Les requêtes que le journal connaît déjà sont servies sans appel au fournisseur, et les outils à effet de bord ne sont jamais réexécutés : personne ne recevra un second e-mail parce que vous testez un modèle. Un rapport compare ensuite les deux versions : issue, réponse, appels, coût, durée.
Une suite d'évaluations (loom eval) fait la même chose sur une série de cas, avec des contrôles déterministes et un juge. À voir dans examples/j6/replay.py et examples/j6/evals.py.
Guide d'installation
Prérequis
- Python 3.14 ou plus récent.
- uv de préférence ; pip fonctionne aussi.
- Linux, la plateforme sur laquelle Loom-IA est développé et testé. La sandbox Firecracker l'exige.
- Une clé d'API pour chaque fournisseur de modèles que vous comptez utiliser. Les essais avec le modèle simulé n'en demandent aucune.
Installer le paquet
Avec pip, dans un environnement virtuel :
python3.14 -m venv .venv
source .venv/bin/activate
pip install "loom-ia[anthropic,http,mcp]"
Avec uv, dans votre projet :
uv add "loom-ia[anthropic,http,mcp]"
Le paquet s'appelle loom-ia, s'importe sous le nom loom_ia et installe la commande loom.
Le noyau ne dépend que de pydantic, pyyaml et jsonschema. Tout le reste arrive par des extras, à choisir selon ce que vous utilisez :
| Extra | À installer si vous utilisez |
|---|---|
anthropic |
Claude, ou un fournisseur qui expose l'API d'Anthropic (MiniMax, par exemple) |
openai |
OpenAI ou un fournisseur compatible (Together, vLLM, Ollama…) |
mcp |
Des serveurs MCP comme outils, ou loom mcp |
http |
L'API REST et le serveur MCP en HTTP (loom serve) |
sqlite |
Le journal ou le magasin d'idempotence en SQLite |
postgres |
Le journal, l'idempotence ou le bus en Postgres |
redis |
Le bus ou l'idempotence en Redis |
rabbitmq |
La file de tâches RabbitMQ et loom worker |
otel |
L'export des traces vers OpenTelemetry |
crypto |
Le chiffrement du journal et des fichiers, client par client |
all |
Tous les extras |
Si la configuration demande un extra absent, Loom-IA refuse de la charger et nomme l'extra à installer :
Configuration : Journal 'postgres' : le paquet 'asyncpg' n'est pas installé (installer l'extra : loom-ia[postgres])
Depuis les sources
git clone https://github.com/denislamard/loom_v2.git
cd loom_v2
uv sync --all-extras
uv installe Python 3.14 s'il le faut et crée l'environnement. Pour vérifier que tout fonctionne, lancez l'agent de démonstration, qui tourne sur un modèle simulé :
uv run loom --config examples/j1/demo/loom.yaml run demo "Combien font 12 × 7 + 3 ?"
Statut : completed · itérations : 2 · tokens : 530/93 · coût : 0.0000 $
Run : 01a11f3e-8783-75c1-8bcc-817c81f9d0d7
12 fois 7, plus 3, font 87.
Les exemples qui appellent de vrais modèles lisent leurs clés dans l'environnement. Le plus simple est un fichier .env à la racine du dépôt, passé à uv :
uv run --env-file .env python examples/j3/juge.py --reel
Pour un déploiement en service
En local, Loom-IA n'a besoin d'aucun service : le journal tient dans des fichiers JSONL et les tâches de fond tournent dans le process. Un service à plusieurs workers s'appuie sur trois briques :
| Service | Rôle |
|---|---|
| Postgres | Journal, idempotence, bus |
| Redis | Bus, idempotence |
| RabbitMQ | File de tâches |
La CI les teste avec Postgres 16, Redis 7 et RabbitMQ 3.12.
La sandbox Firecracker (facultatif)
Le client qui pilote les microVM fait partie du paquet. La plateforme, elle, vit dans le dossier firecracker/ du dépôt et ne s'installe pas avec pip : il faut cloner le dépôt. Elle demande :
- Linux ;
- la virtualisation matérielle, avec
/dev/kvmaccessible ; sudo, pour construire l'image.
cd firecracker
./make_vm.sh agent-01
Le script télécharge dans un cache le binaire Firecracker, un noyau et une image Ubuntu 24.04. Il construit ensuite une VM autonome dans firecracker/vms/agent-01/, avec le service d'exécution execd dans son image. C'est ce dossier que vous donnerez à la source forge (voir Les outils).
Prise en main : votre premier agent
On va construire un petit agent qui répond aux questions de prix d'un artisan. Pour calculer un montant TTC, il passera par un outil Python plutôt que de compter de tête. On le fait d'abord tourner sur un modèle simulé, pour tout vérifier sans clé ni réseau, puis on le branche sur un vrai modèle.
Il vous faut loom-ia[anthropic,http,mcp], installé comme indiqué plus haut.
1. Le dossier du projet
Un projet Loom-IA tient en un fichier de configuration racine, un dossier d'agents et un dossier de prompts :
mon-agent/
├── loom.yaml modèles, stockage, modules à importer
├── outils.py vos outils Python
├── agents/
│ └── devis.yaml un fichier par agent
└── prompts/
└── devis.md le prompt système de l'agent
2. Écrire un outil
outils.py :
from loom_ia.tools import tool
@tool
def prix_ttc(montant_ht: float, taux_tva: float = 20.0) -> dict[str, float]:
"""Calcule la TVA et le montant TTC d'un devis. Le taux est en pourcentage."""
tva = round(montant_ht * taux_tva / 100, 2)
return {"montant_ht": montant_ht, "tva": tva, "montant_ttc": round(montant_ht + tva, 2)}
Le décorateur @tool suffit. Loom-IA déduit de la signature le schéma des arguments, et la docstring devient la description que lit le modèle. Soignez-la : c'est elle qui indique au modèle quand appeler l'outil.
La fonction peut être synchrone ou async. Elle peut renvoyer :
- du texte ;
- un dictionnaire ou un modèle Pydantic, transmis en JSON ;
- une image.
3. Décrire l'agent
agents/devis.yaml :
name: devis
description: Répond aux questions de prix sur les devis.
main:
model: SIMULE
system_file: devis.md
max_iterations: 5
tools:
- python: prix_ttc
main désigne l'orchestrateur, avec son modèle et son prompt système. max_iterations borne le nombre de tours de la boucle ; une fois la borne atteinte, l'agent doit répondre sans outils.
prompts/devis.md :
Tu es l'assistant d'un artisan. Tu réponds en français, en une ou deux phrases.
Pour tout calcul de prix, appelle l'outil `prix_ttc` au lieu de calculer
toi-même, et reprends les montants qu'il renvoie.
4. La configuration racine
loom.yaml :
# yaml-language-server: $schema=./loom.schema.json
version: 1
imports: [outils] # le module outils.py, à côté de ce fichier
agents_dir: agents/
prompts_dir: prompts/
models:
- id: SIMULE
sdk: fake # modèle simulé : ni clé, ni réseau
model: fake-1
params:
script:
- text: Je calcule le prix TTC.
tool_calls:
- name: prix_ttc
arguments: {montant_ht: 1250, taux_tva: 10}
- text: Pour 1 250 € HT avec une TVA à 10 %, votre client paiera 1 375 € TTC.
telemetry:
logging: {level: WARNING} # en INFO, une ligne par appel de modèle et d'outil
storage:
events: {backend: jsonl, path: data} # le journal : un fichier JSONL par session
Le modèle sdk: fake joue un script : à son premier tour il appelle prix_ttc, au second il répond. Rien ne dépend d'un fournisseur, ce qui le rend pratique pour mettre un agent en place ou écrire des tests.
La première ligne relie votre éditeur au schéma de la configuration, pour la complétion et les erreurs en direct (dans VS Code, avec l'extension YAML, par exemple). Générez ce schéma une fois :
loom schema > loom.schema.json
5. Valider, puis lancer
cd mon-agent
loom validate
loom validate charge la configuration et vérifie sa cohérence : modèle inconnu, outil introuvable, extra manquant… Il monte ensuite chaque agent et affiche ce qu'il a compris :
Config : loom.yaml
Profil : aucun (ni --profile, ni LOOM_PROFILE, ni 'profile:') ; surcharges : aucune
Modèles : SIMULE
Agents : devis
Outils : prix_ttc
…
devis : modèle SIMULE, 1 outil(s) Python
1 agent(s) monté(s) sans erreur.
Lancez ensuite un run :
loom run devis "Quel prix TTC pour un devis de 1 250 € HT en rénovation (TVA à 10 %) ?"
Statut : completed · itérations : 2 · tokens : 684/110 · coût : 0.0000 $
Run : 01a11f47-ae95-7161-91ab-ee7c2558ef41
Pour 1 250 € HT avec une TVA à 10 %, votre client paiera 1 375 € TTC.
Avec --stream, la réponse s'affiche au fil de l'eau, appels d'outils compris.
6. Regarder ce qui s'est passé
Tout est dans le journal, data/default/<session>.jsonl. Pour le lire sans plonger dans le JSON :
loom inspect 01a11f47-ae95-7161-91ab-ee7c2558ef41
run devis — completed, 80 ms, 0.000000 $
étape 1
modèle fake-1 (main) — 1 ms, 259 → 68 tokens, 0.000000 $
· répond : Je calcule le prix TTC.
· appelle : prix_ttc({"montant_ht": 1250, "taux_tva": 10})
étape 2
outil prix_ttc — 5 ms
· arguments : {"montant_ht": 1250, "taux_tva": 10}
· résultat : {"montant_ht": 1250.0, "tva": 125.0, "montant_ttc": 1375.0}
étape 3
modèle fake-1 (main) — 0 ms, 425 → 42 tokens, 0.000000 $
· répond : Pour 1 250 € HT avec une TVA à 10 %, votre client paiera 1 375 € TTC.
loom report <run_id> détaille la consommation par rôle et par modèle. Avec --session, il couvre toute une conversation.
7. Brancher un vrai modèle
Ajoutez un modèle dans la liste models de loom.yaml :
- id: HAIKU
sdk: anthropic
model: claude-haiku-5-5
api_key_env: ANTHROPIC_API_KEY # le nom de la variable, jamais la clé elle-même
max_tokens: 16000 # la réflexion du modèle compte dedans
pricing: {input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125} # $ par million de tokens
Faites-le ensuite utiliser par l'agent, dans agents/devis.yaml :
main:
model: HAIKU
system_file: devis.md
Puis lancez le même run :
export ANTHROPIC_API_KEY=...
loom run devis "Quel prix TTC pour un devis de 1 250 € HT en rénovation (TVA à 10 %) ?"
pricing est facultatif. Sans lui, Loom-IA ne sait pas calculer de coût, et ne peut donc pas appliquer un budget en dollars.
Les fournisseurs compatibles avec OpenAI passent par sdk: openai et une adresse. Un modèle local servi par Ollama, par exemple, n'a besoin d'aucune clé :
- id: LOCAL
sdk: openai
api: chat
base_url: http://localhost:11434/v1
model: qwen3:8b
8. Appeler l'agent depuis Python
import asyncio
from loom_ia.access import Loom
async def main() -> None:
async with Loom.from_config("loom.yaml") as loom:
result = await loom.run("devis", "Quel prix TTC pour 1 250 € HT à 10 % ?")
print(result.status, result.text)
asyncio.run(main())
run() attend la fin du run et renvoie un RunResult : le statut, le texte de la réponse, la consommation, et les appels en attente d'approbation s'il y en a.
Pour suivre le run en direct, stream() mêle les morceaux de texte du modèle et les événements du journal :
from loom_ia.core.events import Event
from loom_ia.core.model import TextDelta
# dans le bloc « async with » de l'exemple précédent
async for item in loom.stream("devis", "Quel prix TTC pour 1 250 € HT à 10 % ?"):
if isinstance(item, TextDelta):
print(item.text, end="", flush=True)
elif isinstance(item, Event) and item.type == "tool.called":
print("\n[appel d'outil]")
Loom-IA écrit ses logs avec le module logging de la bibliothèque standard et ne touche jamais aux handlers de votre application. Si vous voulez les siens, appelez loom_ia.telemetry.configure_logging().
9. L'ouvrir en REST et en MCP
loom serve
L'API écoute sur http://127.0.0.1:8000, et sa documentation interactive se trouve sur /docs.
curl -X POST http://127.0.0.1:8000/v1/agents/devis/runs \
-H 'Content-Type: application/json' \
-d '{"message": "Quel prix TTC pour 1 250 € HT à 10 % ?"}'
La réponse contient le statut, le texte, l'usage, le coût et sa ventilation. Avec "background": true, l'appel rend tout de suite l'identifiant du run (code 202), et vous suivez son déroulé en SSE :
curl -N http://127.0.0.1:8000/v1/runs/<run_id>/events
Tant qu'aucune clé d'API n'est déclarée, l'API est ouverte. C'est commode sur votre machine, pas sur un serveur : la section Plusieurs clients et sécurité explique comment créer des clés.
Pour brancher l'agent sur Claude Code :
claude mcp add loom -- loom --config /chemin/vers/mon-agent/loom.yaml mcp
Pour Claude Desktop, dans claude_desktop_config.json :
{
"mcpServers": {
"loom": {
"command": "/chemin/vers/.venv/bin/loom",
"args": ["--config", "/chemin/vers/mon-agent/loom.yaml", "mcp"]
}
}
}
L'agent devis devient un outil MCP qui prend un message. Trois outils de contrôle l'accompagnent : run_status, run_report et cancel.
10. Et ensuite
Trois ajouts reviennent presque toujours.
Une conversation. Avec un identifiant de session, chaque run relit l'historique et y ajoute ses échanges :
loom run devis "Quel prix TTC pour 1 250 € HT à 10 % ?" --session chantier-durand
loom run devis "Et avec une TVA à 20 % ?" --session chantier-durand
Une validation humaine. Un outil qui agit sur le monde le déclare :
@tool(side_effects="irreversible", approval="always")
def envoyer_email(destinataire: str, objet: str, corps: str) -> str:
"""Envoie un e-mail au client."""
...
Le run s'arrête alors avant l'envoi, en statut paused, et loom run indique la commande à lancer pour trancher :
Statut : paused · itérations : 1 · tokens : 193/57 · coût : 0.0000 $
Run : 01a11f3e-5a5e-7338-a41d-8b0c1ae05e25
En attente : envoyer_email (fake_0_0) — loom approve 01a11f3e-5a5e-7338-a41d-8b0c1ae05e25 --call fake_0_0
loom approve <run_id> reprend le run. loom reject <run_id> --reason "…" renvoie le refus et son motif au modèle. Une pause exige un journal durable, puisque le run peut attendre des heures et le process s'arrêter entre-temps.
Un rôle, un contrat, un juge. Un rôle délégué rédige, un contrat vérifie la forme de sa sortie, un juge en vérifie le fond. Ces trois mécanismes sont détaillés dans la partie technique.
Partie technique : les fonctions de Loom-IA
Cette partie fait le tour de ce que Loom-IA sait faire, domaine par domaine. Les extraits de configuration sont partiels ; le schéma complet s'obtient avec loom schema.
- Architecture
- Le run : une machine à états et un journal
- La configuration
- Les modèles
- Rôles et sous-agents
- Les outils
- Politiques, contrats et juges
- Sessions et historique
- Exécution durable
- Coûts, budgets et quotas
- Plusieurs clients et sécurité
- Observabilité, rejeu et tests
- Les accès en détail
- Les stockages
Architecture
Accès API Python · REST · MCP · CLI
Application Agents · Runtime · Sessions · Clients · Rejeu et évaluations
Moteur Boucle · Exécution des outils · Rôles · Contrôles · Politiques
Noyau Modèle de domaine · Ports · Événements
Adaptateurs Modèles · MCP · Journaux · Bus · Files · Sandbox
Le noyau ne fait aucune entrée-sortie. Il définit des types (messages, état d'un run, événements) et des interfaces, les « ports » : appeler un modèle, fournir des outils, écrire le journal, ranger un fichier, mettre une tâche en file, lire un secret. Les adaptateurs implémentent ces ports en périphérie. Chacun ne charge son SDK que si la configuration le demande. Ni le noyau ni le moteur n'importent un SDK de modèle, FastAPI ou MCP. La CI vérifie ces règles à chaque passage avec import-linter.
Tous les types du domaine sont des modèles Pydantic. Ils se sérialisent tels quels dans le journal et en HTTP, et ils produisent les schémas JSON de la configuration et des événements.
Un message est une liste de blocs typés, indépendante du fournisseur : du texte, une image ou un fichier (par référence, jamais les octets), un appel d'outil, un résultat d'outil, un raisonnement. Chaque adaptateur traduit ce format au moment de l'appel. Une conversation peut donc changer de fournisseur en cours de route, et un run peut basculer sur un modèle de secours sans perdre son historique.
Le run : une machine à états et un journal
Un run avance d'état en état :
| État | Ce qui se passe |
|---|---|
ready_for_model |
L'orchestrateur est appelé |
awaiting_tools |
Les outils demandés s'exécutent, en parallèle |
waiting_child |
Un sous-agent attend une validation |
paused |
Le run attend une décision humaine ; rien ne tourne |
finalizing |
Itérations ou budget épuisés : une dernière réponse, sans outils |
completed, failed, cancelled |
Fin du run |
Le pilote suit une règle simple. Il recalcule l'état à partir du journal, exécute un seul effet par étape (un appel de modèle ou un lot d'outils), et écrit chaque événement avant de l'appliquer à l'état.
Un run n'est donc jamais stocké en tant que tel : comme l'historique de la conversation, les coûts ou l'arbre des traces, c'est une projection de son journal. On reprend un run en relisant son journal, une pause ne coûte rien puisque rien ne tourne, et n'importe quel worker peut piloter n'importe quel run.
Chaque événement a une enveloppe commune et une charge typée. L'enveloppe porte un identifiant UUIDv7 triable dans le temps, la position de l'événement dans la session, le client, la session, le run, le span, le type et le statut. Les types suivent la forme <catégorie>.<action> : run.started, model.responded, tool.called, tool.completed, guard.checked, judge.evaluated, approval.requested, budget.exceeded, run.completed… Le schéma est versionné. C'est le contrat sur lequel une interface de suivi peut s'appuyer.
Un run se termine quand l'orchestrateur répond sans appeler d'outil, quand un outil terminal a rendu sa sortie, ou quand on l'arrête. Quand les itérations ou le budget s'épuisent, Loom-IA force une dernière réponse sans outils plutôt que de laisser l'utilisateur sans rien. Le timeout d'un agent borne le temps de pilotage cumulé de ses runs ; au-delà, le run échoue mais reste reprenable.
Un run s'arrête de l'extérieur par Loom.cancel(), par POST /v1/runs/{id}/cancel ou par l'outil MCP cancel. L'arrêt se propage à ses sous-agents.
La configuration
La configuration s'écrit en YAML : un fichier racine, loom.yaml, un fichier par agent dans agents/, et les prompts à part dans prompts/. Pydantic la valide strictement au chargement, et chaque erreur dit ce qui ne va pas et où. Les secrets n'y figurent jamais : la configuration ne contient que des noms de variables d'environnement.
Le fichier racine regroupe les sections suivantes :
version: 1
profile: dev # dev | prod
imports: [outils, politiques] # modules Python chargés au démarrage
agents_dir: agents/
prompts_dir: prompts/
models: [...] # les modèles disponibles
mcp_servers: [...] # les serveurs MCP
tool_sources: [...] # les sources d'outils de paquets installés
storage: {...} # journal, fichiers, idempotence, file, bus, chiffrement, rétention
sessions: {...} # snapshots et résumés de conversation
execution: {...} # délais, pièces jointes
budgets: {...} # plafonds par défaut
telemetry: {...} # logs, capture, masquage, exports
tenants: [...] # les clients
security: {...} # clés d'API
server: {...} # adresse HTTP, MCP en HTTP
triggers: [...] # webhooks entrants
profiles: {prod: {...}} # surcharges par profil
Références vers votre code. Un outil, une politique ou une condition de juge se désigne par un nom enregistré, ou par un chemin module:attribut. Les modules voisins de loom.yaml s'importent directement, et deux configurations chargées dans un même process gardent chacune les leurs.
Variables dans les prompts. Les prompts et les gabarits acceptent des variables {{ … }}. Une variable inconnue est une erreur de chargement, pas un trou silencieux dans le prompt.
Profils. Il y en a deux, dev et prod. Le profil actif se choisit, du plus fort au plus faible, par --profile, par la variable LOOM_PROFILE, puis par la clé profile: du fichier. prod change certains avertissements en erreurs : un juge qui utilise le même modèle que ce qu'il juge, un modèle sans tarif sous un budget en dollars, une API REST sans clé. dev tolère au contraire ce que la production refuserait, comme un agent qui peut se mettre en pause sur un journal non durable. Sans profil, rien n'est durci ni assoupli.
Contrôles au démarrage. Le chargement refuse notamment :
- un modèle ou un rôle inconnu ;
- une référence Python introuvable ;
- un extra manquant ;
- une décision de politique interdite à son point ;
- un serveur MCP référencé mais non déclaré ;
- un modèle de secours incapable de faire ce qu'on lui demande.
Rechargement en développement. loom serve --reload surveille le dossier de la configuration. À chaque changement, un nouveau process la charge pendant que l'ancien continue de servir. Si la nouvelle configuration est cassée, elle est refusée avec la raison, et l'ancien process continue. Cette option est refusée en profil prod.
Sans fichier. Tout se construit aussi en Python, avec les mêmes modèles :
from loom_ia.access import Loom
from loom_ia.agents import AgentSpec, MainRole, PythonTool
from loom_ia.config import LoomConfig
from loom_ia.core.model import ModelSpec
config = LoomConfig(
version=1,
models=(
ModelSpec(
id="HAIKU", sdk="anthropic", model="claude-haiku-5-5", api_key_env="ANTHROPIC_API_KEY"
),
),
agents=(
AgentSpec(
name="devis",
main=MainRole(model="HAIKU", system="Tu es l'assistant d'un artisan."),
tools=(PythonTool(python="prix_ttc"),),
),
),
)
loom = Loom(config)
loom.register("prix_ttc", prix_ttc)
Les modèles
Deux adaptateurs couvrent l'essentiel du marché. sdk: anthropic sert Claude et les fournisseurs qui exposent la même API. sdk: openai sert OpenAI et les fournisseurs compatibles (Together, vLLM, Ollama…), avec deux API au choix, api: chat ou api: responses. Sans base_url, c'est l'adresse officielle du fournisseur qui est utilisée.
models:
- id: M3
sdk: anthropic
base_url: https://api.minimax.io/anthropic
model: MiniMax-M3
api_key_env: M3_API_KEY
max_tokens: 4096
params: {temperature: 0.3} # transmis tel quel au fournisseur
timeouts: {first_token: 60, idle: 60, total: 300}
retry: {max_attempts: 3, max_delay: 30}
circuit_breaker: {failures: 5, cooldown: 60}
pricing: {input: 0.30, output: 1.20, cache_read: 0.06}
Et dans l'agent :
main:
model: M3
fallbacks: [HAIKU]
Erreurs et secours. Loom-IA classe les erreurs avant de décider quoi faire :
- une erreur passagère (429, 5xx, délai dépassé, réseau) est retentée avec un délai exponentiel, en respectant
Retry-After; - un quota épuisé bascule tout de suite sur le modèle de secours ;
- une erreur d'authentification ou une requête invalide ne sont jamais retentées.
La chaîne de secours se déclare pour l'orchestrateur, pour chaque rôle et pour chaque juge. Un run qui a basculé reste sur le secours jusqu'à la fin.
Un disjoncteur par modèle écarte un fournisseur en panne pour tous les runs de l'instance. Par défaut, il s'ouvre après 5 échecs et le fournisseur est écarté pendant 60 secondes.
Streaming. Le streaming est la primitive de base : chaque appel de modèle est un flux, et une réponse complète n'est que ce flux rassemblé. Trois délais le surveillent : l'attente du premier token, le silence entre deux morceaux et la durée totale.
Capacités. Chaque modèle déclare ce qu'il sait faire : outils, vision, raisonnement, JSON natif, taille de la fenêtre de contexte. Loom-IA vérifie au chargement qu'on ne demande pas à un modèle ce qu'il ne sait pas faire. Les images sont transmises en base64 aux modèles capables de les lire ; les autres reçoivent une mention du fichier.
Raisonnement et cache. Le raisonnement du modèle est conservé sous une forme neutre et renvoyé quand le fournisseur l'exige pendant une boucle d'outils. Avec sdk: anthropic, cache: {system: true, tools: true, messages: true} pose les points de cache de prompt. Quand le modèle le permet, une sortie structurée part avec le schéma JSON natif du fournisseur.
Rôles et sous-agents
Un rôle est un modèle spécialisé que l'orchestrateur appelle comme un outil. Il fait un seul appel de modèle, sans historique ni outils, et ne reçoit que ce qu'il déclare :
roles:
- name: rediger_email
description: Rédige l'e-mail qui accompagne le devis.
model: HAIKU
system: Tu rédiges des e-mails courts et polis pour un artisan.
input_schema:
type: object
properties:
ton: {type: string, description: "cordial, ferme, bref…"}
required: [ton]
context: [user_input, {tool_results: [prix_ttc]}]
input_template: |-
Demande de l'artisan : {{ context.user_input }}
Montants calculés : {{ context.tool_results.prix_ttc }}
Ton : {{ args.ton }}
terminal: true
Ce que le rôle peut recevoir :
| Contexte | Contenu |
|---|---|
user_input |
La demande de l'utilisateur, mot pour mot |
attachments |
Les pièces jointes du run |
tool_results: [noms] |
Les résultats de ces outils dans le run (scope: session remonte aussi dans la conversation) |
session_summary |
Le dernier résumé de la conversation |
last_turns: N |
Les N derniers échanges de la session |
caller_context |
Les métadonnées passées par l'appelant |
Plutôt que de recopier un long résultat dans les arguments d'un rôle, l'orchestrateur peut passer une référence, {"$ref": "result:3"}. Loom-IA la résout au moment de l'appel : le contenu complet arrive au rôle, et l'orchestrateur ne paie pas deux fois les mêmes tokens.
Il existe deux sortes de rôles particuliers :
- Le rôle terminal (
terminal: true) : quand il est appelé seul dans son tour et réussit, sa sortie devient la réponse finale telle quelle, sans repasser par l'orchestrateur. - Le rôle vision : il déclare le contexte
attachments. Il n'est proposé à l'orchestrateur que si le run contient une image, et son modèle doit avoir la capacitévision.
Un sous-agent est un agent complet, appelé comme un outil :
subagents:
- agent: verificateur
name: verifier
description: Vérifie les dates et les calculs de la réponse.
budget_share: 0.3 # part de ce qui reste au budget du parent
Le sous-agent tourne avec ses propres outils et sa propre boucle, dans un run enfant écrit dans le même journal. Seule sa réponse finale revient au parent, et sa consommation s'ajoute à celle du parent. max_depth, sur l'agent, limite la profondeur d'imbrication (1 par défaut). L'annulation du parent se propage aux enfants. Quand un enfant attend une validation, ses demandes remontent jusqu'au run racine, et c'est là qu'on les tranche.
Les outils
Outils Python. Le décorateur @tool accepte des déclarations qui changent la façon dont Loom-IA traite l'outil :
from loom_ia.core.ports import ToolContext
from loom_ia.tools import tool
@tool(side_effects="irreversible", approval="always", timeout=20)
async def envoyer_email(destinataire: str, objet: str, corps: str, ctx: ToolContext) -> str:
"""Envoie un e-mail au client."""
...
| Déclaration | Valeurs | Effet |
|---|---|---|
side_effects |
none, reversible, irreversible |
Décide de ce qu'on peut relancer après un plantage, et de ce qu'un rejeu ne réexécutera jamais |
approval |
never, always, policy |
Validation humaine avant l'appel |
idempotent |
true, false |
L'outil peut être relancé sans risque |
on_unknown |
error, pause |
Que faire d'un appel interrompu dont on ne sait pas s'il a abouti |
timeout, offload_over |
secondes, caractères | Délai de l'appel, seuil de déport des gros résultats |
La plupart de ces réglages peuvent aussi se poser dans la configuration de l'agent, qui a le dernier mot. Un paramètre annoté ToolContext reçoit le contexte de l'appel, avec notamment sa clé d'idempotence, et n'apparaît pas dans le schéma. Une exception devient un résultat d'erreur pour le modèle ; ToolError("…") lui donne un message qu'il peut exploiter.
Serveurs MCP. Un serveur se déclare une fois à la racine, puis plusieurs agents peuvent s'en servir :
mcp_servers:
- name: crm
transport: http # stdio | http
url: https://crm.example/mcp
headers_env: {Authorization: CRM_TOKEN} # en-tête ← variable d'environnement
scope: tenant # shared | tenant | run
tools:
envoyer_email: {side_effects: irreversible, approval: always}
- name: math
transport: stdio
command: python
args: [serveurs/serveur_math.py]
scope: run
Dans l'agent :
tools:
- mcp: crm
include: [rechercher, fiche_client, envoyer_email]
- mcp: math
alias: m # préfixe raccourci : m__calculer
required: true # sans lui, le run échoue
Les outils sont préfixés par leur serveur (crm__rechercher), ce qui évite les collisions entre serveurs. La portée règle la durée de vie de la connexion :
| Portée | Connexion |
|---|---|
shared |
Une seule pour tout le process |
tenant |
Une par client, avec ses propres identifiants |
run |
Ouverte et fermée avec chaque run, pour les serveurs qui gardent un état |
Les annotations MCP (readOnlyHint, destructiveHint, idempotentHint) donnent les valeurs par défaut, et la configuration les surcharge. Un serveur injoignable voit ses outils retirés du run, avec une trace dans le journal ; les autres serveurs restent utilisables. La reconnexion se fait avec un délai croissant, sous la garde d'un disjoncteur.
Sources d'outils de paquets installés. Un paquet Python peut fournir des outils par un point d'entrée du groupe loom_ia.tools. La configuration déclare la source, et un agent la référence comme un serveur MCP :
tool_sources:
- name: carnet
entry_point: carnet
params: {fichier: carnet.json}
# dans l'agent
tools:
- source: carnet
Rien n'est importé tant qu'aucun agent n'utilise la source. loom validate liste les sources installées et leurs outils.
La sandbox forge. Loom-IA déclare lui-même une source d'outils, forge, adossée à une microVM Firecracker :
tool_sources:
- name: forge
entry_point: forge
params:
vm_dir: /chemin/vers/firecracker/vms/agent-01
catalog_dir: outils_forges # relatif à loom.yaml
limits: {wall_ms: 10000}
L'agent qui la référence voit forge__forge pour écrire un outil, forge__call pour l'appeler tout de suite, et chaque outil déjà accepté, comme forge__total_ttc. L'hôte se contente de lire le code reçu (syntaxe, signature) ; seule la VM l'exécute. La VM démarre au premier appel qui en a besoin et s'arrête avec l'instance.
Ce qui se passe à chaque appel.
- Les arguments sont validés contre le schéma ; une erreur revient au modèle pour qu'il corrige.
- Les politiques
before_toolpassent : droits, approbation, refus. - L'outil s'exécute, avec un délai de 30 secondes par défaut.
- Les appels d'un même tour tournent en parallèle, mais leurs résultats reviennent au modèle dans l'ordre des appels.
- Un résultat de plus de 50 000 caractères est déporté dans le stockage de fichiers. Le modèle n'en voit qu'un aperçu, et un outil
artifact_readapparaît pour lire la suite.
Fichiers et pièces jointes. Les pièces jointes sont contrôlées à l'entrée : images JPEG, PNG, GIF ou WebP, reconnues par leur signature binaire, 5 Mio et 10 fichiers au plus par défaut. Elles sont rangées hors du journal, sous une adresse artifact://<client>/<session>/<empreinte>.<ext>. Un outil peut aussi produire des fichiers ; l'appelant les récupère dans RunResult.artifacts.
Politiques, contrats et juges
Politiques. Une politique est une fonction branchée sur un point de la boucle, qui rend une décision :
| Point | Continuer | Remplacer | Réparer | Refuser | Pause | Arrêter | Échouer |
|---|---|---|---|---|---|---|---|
before_model |
✓ | la requête | ✓ | ✓ | |||
after_model |
✓ | ✓ | ✓ | ✓ | |||
before_tool |
✓ | les arguments | ✓ | ✓ | ✓ | ||
after_tool |
✓ | le résultat | ✓ | ✓ | |||
on_output |
✓ | la réponse | ✓ | ✓ |
Voici, par exemple, une politique qui refuse un taux de TVA qui n'existe pas en France :
from loom_ia.policies import CONTINUE, BeforeTool, Decision, Deny, PolicyContext, policy
@policy(points=["before_tool"], decisions=["deny"])
def taux_de_tva(subject: BeforeTool, context: PolicyContext) -> Decision:
"""Refuse un taux de TVA qui n'existe pas en France."""
if subject.spec.name != "prix_ttc":
return CONTINUE
autorises = context.params.get("taux", [20, 10, 5.5, 2.1])
taux = subject.arguments.get("taux_tva", 20)
if isinstance(autorises, list) and taux not in autorises:
return Deny(f"taux de TVA {taux} % inconnu ; taux possibles : {autorises}")
return CONTINUE
Elle se branche dans l'agent :
policies:
- hook: taux_de_tva
params: {taux: [20, 10, 5.5, 2.1]}
Le modèle reçoit le refus et son motif, et peut corriger son appel.
Les politiques s'exécutent dans l'ordre déclaré. Chacune a un délai (5 secondes par défaut) et un comportement en cas d'exception (on_error: block par défaut). Toute décision autre que « continuer » est écrite dans le journal, et une décision interdite à un point est une erreur de configuration, détectée au démarrage.
Loom-IA fournit loom.require_tool, qui impose au modèle d'appeler un outil tant qu'il n'en a appelé aucun. Les contrats, les juges et les budgets sont eux aussi des politiques fournies.
Contrats. Un contrat vérifie la forme d'une sortie. Il se pose sur la réponse finale de l'agent, sur un rôle ou sur un outil :
output:
schema:
type: object
properties:
objet: {type: string, minLength: 5}
corps: {type: string, minLength: 20}
required: [objet, corps]
must_not_match: "(?i)à compléter|xxx" # expression régulière
max_chars: 4000
repair: {max_attempts: 1}
on_failure: fail # fail | unverified | fallback
Une sortie non conforme passe par trois étapes :
- La normalisation. La sortie est d'abord nettoyée sans modèle : bloc de code retiré, JSON extrait du texte.
- La réparation. Si la sortie reste non conforme, le modèle qui l'a produite reçoit sa sortie et le diagnostic, et corrige dans sa propre conversation.
on_failure. Si la réparation échoue, ce réglage décide : échec, sortie gardée mais marquéeunverified, ou message de repli.
Avec un schéma, l'objet JSON validé arrive dans RunResult.data. Un outil, lui, n'est jamais réparé : l'orchestrateur reçoit son résultat en erreur, avec le diagnostic.
Juges. Un juge est un modèle qui note une sortie selon des critères écrits, entre 0 et 1 :
judge:
model: HAIKU
context: [user_input, {tool_results: [chercher_devis]}]
criteria:
- name: fidele
rule: >-
Chaque montant, date et délai de l'e-mail figure dans le devis ou
dans la demande de l'artisan : rien d'inventé.
- name: ton
rule: Le ton de l'e-mail est celui demandé.
min_score: 0.6
blocking: false
repair: {max_attempts: 1}
on_failure: fail
Un critère bloquant sous son seuil fait refuser la sortie, qui passe alors en réparation. Un critère non bloquant est seulement signalé.
C'est le code qui décide si le juge passe, jamais le modèle :
whenpeut combiner un échantillon tiré de façon déterministe (sample: 0.2pour un run sur cinq), une condition Python, une liste de clients et une liste de profils ;- l'appelant peut forcer tous les juges (
judges="force") ou les sauter (judges="skip", refusé en profilprod) ; - un juge qui ne passe pas laisse quand même une trace dans le journal, avec la raison.
Si un juge utilise le même modèle que ce qu'il juge, Loom-IA le signale ; en prod, c'est une erreur.
Sessions et historique
Avec un session_id, les runs d'une conversation partagent un journal. Chaque run relit l'historique, qui garde sa structure (appels d'outils compris) quel que soit le fournisseur. Les tentatives refusées et le raisonnement en sont exclus. Un snapshot de l'historique, écrit à la fin des runs, évite de tout reconstruire à chaque fois.
Quand une conversation s'allonge, elle est résumée en tâche de fond, après le run, sans faire attendre l'utilisateur :
sessions:
compaction:
model: HAIKU
over_tokens: 12000 # au-delà, la session est résumée après le run
hard_tokens: 150000 # au-delà, résumé immédiat avant le run
keep_last: 6 # derniers échanges gardés tels quels
fidelity_check: true
Le résumé ne réécrit rien : il s'ajoute au journal. Un contrôle déterministe vérifie que les références, les adresses e-mail et les nombres du passage résumé se retrouvent dans le résumé. Si l'un d'eux manque, le modèle recommence.
Pour le RGPD, une session se liste (loom sessions list), s'exporte (loom sessions export <id>, GET /v1/sessions/{id}/events) et se supprime (loom sessions delete <id>, DELETE /v1/sessions/{id}). La suppression emporte aussi ses fichiers et ses clés d'idempotence.
Exécution durable
Reprise après plantage. Chaque événement écrit est un point de sauvegarde. loom resume <run_id> (ou Loom.resume()) reprend un run interrompu là où son journal s'est arrêté. Au démarrage d'un process, un appel à Loom.recover() remet en file tous les runs restés en plan.
Un outil déjà terminé n'est jamais relancé. Un outil interrompu n'est relancé que s'il est sans effet de bord ou idempotent. Dans les autres cas, on ne sait pas s'il a agi. L'outil choisit alors avec on_unknown : error (par défaut) en informe le modèle, pause fait vérifier un humain avant toute nouvelle tentative.
Un seul pilote par run. Le worker qui pilote un run prend une concession et la renouvelle tant qu'il travaille ; le bail dure 60 secondes par défaut. Si le worker meurt, la concession expire et un autre reprend le run.
Arrière-plan. Loom.submit() ou "background": true en REST inscrivent le run au journal avant de rendre la main : il est aussitôt suivable, interrogeable et annulable. Loom.result(run_id) relit ensuite ce qu'il a produit.
Validation humaine. Par défaut, l'approbation est asynchrone. Le run passe en paused et rend pending_approvals ; un humain tranche plus tard, en corrigeant les arguments s'il le faut, et le run reprend, sur ce process ou sur un autre. L'auteur de chaque décision est inscrit au journal. Pour les scripts et les tests, un approbateur en ligne décide dans la boucle, sans pause : loom.run(..., approver=fonction).
Les demandes expirent après 24 heures par défaut. Le délai et le sort d'une demande expirée se règlent sur l'agent :
approval: {expires_in: 3600, on_expiry: deny} # deny | fail
Idempotence. @idempotent mémorise l'effet d'un outil sous une clé. Par défaut, la clé est technique : elle identifie l'appel, et protège la reprise de ce même appel. Une clé métier empêche en plus de refaire la même action depuis deux runs différents :
from loom_ia.tools import idempotent, tool
@idempotent(key=lambda args: f"relance:{args['numero']}")
@tool(side_effects="irreversible", approval="always")
async def envoyer_relance(numero: str, destinataire: str, corps: str) -> str:
"""Envoie la relance d'un devis au client."""
...
Une clé métier exige un magasin partagé et durable (sqlite, postgres ou redis), ce que le chargement vérifie :
storage:
idempotency: {backend: sqlite, path: data/cles.db}
Webhooks entrants. Une porte déclarée ouvre un run en arrière-plan quand on l'appelle :
triggers:
- name: devis-signe # POST /v1/hooks/devis-signe
agent: relance
message: "Le devis {{ payload.devis.numero }} de {{ payload.client.nom }} vient d'être signé."
session: "devis-{{ payload.devis.numero }}"
delivery_header: X-Delivery-Id # une relivraison retrouve son run au lieu d'en ouvrir un autre
Le client du run vient de la clé d'API de l'appelant. Loom-IA ne tient aucun cron : une tâche planifiée appelle simplement la porte à l'heure voulue.
Workers. Avec storage: {queue: {backend: rabbitmq, url_env: LOOM_AMQP}}, l'instance qui reçoit les demandes publie les tâches, et loom worker les exécute. Lancez-en autant que nécessaire. Un run passe d'un worker mort à un worker vivant sans intervention.
Coûts, budgets et quotas
Loom-IA compte les tokens de chaque appel : entrée, sortie, cache, raisonnement. Il les multiplie par le tarif du modèle réellement utilisé, secours compris, avec des paliers possibles au-delà d'un certain volume d'entrée. Le coût se ventile par run, par rôle, par modèle, par session et par client. Celui d'un sous-agent remonte à son parent.
budgets:
run: {max_cost: 0.05, max_tokens: 200000, max_calls: 25}
session: {max_cost: 1.0}
tenant: {max_cost_per_day: 10.0, max_tokens_per_month: 20000000}
on_exceed: stop # warn | stop
Un agent surcharge ces valeurs clé par clé avec sa propre section budget. Un sous-agent reçoit une part de ce qui reste à son parent (budget_share).
Les budgets ne se contrôlent pas tous au même moment :
- Budget de run et de session : contrôlé avant chaque appel de l'orchestrateur. S'il est dépassé avec
stop, le run passe enfinalizinget produit une dernière réponse sans outils, ce qui borne le dépassement à une génération. - Budget de client : contrôlé au lancement du run, sur des fenêtres calendaires (jour, mois) en UTC. Un run refusé n'écrit rien.
Deux limites de débit complètent les budgets : un quota de runs par minute pour chaque client (quotas: {runs_per_minute: 30}) et un débit par clé d'API. Les deux reposent sur une fenêtre glissante d'une minute. En REST, un dépassement rend un 429 avec Retry-After.
Le rapport de consommation se lit avec loom report (d'un run, d'une session avec --session, ou d'un client sur la journée ou le mois avec --tenant et --periode), en Python avec Loom.report() et Loom.consumption(), en REST sur GET /v1/sessions/{id}/report et en MCP avec l'outil run_report.
Plusieurs clients et sécurité
Tant que la configuration ne déclare aucun client, tout se passe chez le client default. Dès qu'elle en déclare, la liste devient fermée, et un client inconnu est refusé.
tenants:
- id: dupont-plomberie
variables: {entreprise: Plomberie Dupont} # injectées dans les prompts
secrets: {CRM_TOKEN: DUPONT_CRM_TOKEN} # son jeton CRM, dans sa propre variable
budgets: {tenant: {max_cost_per_day: 5.0}}
quotas: {runs_per_minute: 30}
- id: martin-chauffage
variables: {entreprise: Chauffage Martin}
models: {GLM_FLASH: HAIKU} # un autre modèle pour ce client
approvals: {envoyer_email: always}
secrets: {CRM_TOKEN: MARTIN_CRM_TOKEN}
storage:
events: {backend: jsonl, path: data/martin} # son propre journal
Un client ne surcharge qu'une liste fermée de réglages : les agents autorisés et les outils retirés, la correspondance des modèles, les budgets et les quotas, les approbations, les secrets, les variables des prompts et le stockage. Les prompts eux-mêmes ne se surchargent pas ; seules les variables qu'ils contiennent changent d'un client à l'autre. Un serveur MCP en scope: tenant ouvre une connexion par client, avec ses identifiants.
Clés d'API. Une clé se fabrique en ligne de commande :
loom keys create app-dupont --tenant dupont-plomberie \
--scope run --scope read --scope read_content --scope approve --expires 90j
La clé s'affiche une seule fois. La configuration n'en garde que l'empreinte :
security:
api_keys:
- id: app-dupont
tenant: dupont-plomberie
hash: sha256:…
scopes: [run, read, read_content, approve]
agents: [relance]
rate_limit: {per_minute: 60}
expires: 2027-01-08T00:00:00Z
Une requête présente sa clé dans Authorization: Bearer lk_… ou dans X-API-Key. La clé détermine le client, et rien dans le corps de la requête ne peut le changer.
| Portée | Autorise |
|---|---|
run |
Lancer et arrêter un run |
read |
Lire les agents, statuts, événements et rapports, sans le contenu |
read_content |
Lire aussi le contenu : demandes, réponses, arguments |
approve |
Trancher une demande d'approbation (avec read_content, pour voir ce qu'on approuve) |
admin |
Effacer une session, lancer un run sans ses juges |
Isolation des données. Avec un journal Postgres, une politique de sécurité au niveau des lignes filtre chaque requête sur le client courant. L'application tourne sous un rôle sans droit de modification sur le journal. loom storage sql imprime le schéma à faire appliquer par un DBA, si le rôle ne peut pas le créer lui-même.
Chiffrement. storage: {encryption: {keys: [LOOM_JOURNAL_KEY]}} scelle en AES-256-GCM le contenu de chaque événement et de chaque fichier. Il faut l'extra crypto. Chaque client redirige ce nom de secret vers sa propre clé : effacer celle d'un client rend son journal illisible pour de bon, sans toucher aux autres. Les sessions restent listables et supprimables même sans la clé.
Rétention. storage: {retention: {events_days: 365}} fixe la durée de conservation ; chaque client peut avoir la sienne. loom retention liste les sessions restées sans activité au-delà de cette durée. L'option --yes les efface.
MCP en HTTP. Avec server: {mcp: {http: true}}, le serveur MCP est monté dans l'application REST, sous /mcp. Il exige une clé à chaque requête et applique les mêmes portées qu'en REST ; les en-têtes Origin et Host sont validés. Un seul serveur sert ainsi tous les clients.
Observabilité, rejeu et tests
Traces. Chaque run se lit comme un arbre : le run, ses étapes, les appels de modèles, les outils, les validations, les sous-agents. Chaque élément porte sa durée, son statut et son coût. On y accède de plusieurs façons :
- en ligne de commande :
loom inspect <run_id>affiche l'arbre lisible (--fullpour tout voir,--jsonpour la version brute) ; - en Python :
Loom.trace(run_id); - en REST :
GET /v1/traces/{run_id}; - en MCP : la ressource
loom://traces/{run_id}.
L'API rend la même trace, sans le contenu si la clé n'a pas read_content.
OpenTelemetry. Les traces s'exportent vers n'importe quel collecteur OTLP, à la clôture de chaque run, avec les conventions GenAI d'OpenTelemetry :
telemetry:
capture: {exports: metadata} # metadata | content
redaction:
patterns: [email, phone, iban, {name: devis, regex: "D-\\d{4}-\\d{3}"}]
exporters:
- type: otel
endpoint_env: OTEL_EXPORTER_OTLP_ENDPOINT
protocol: http/protobuf
En metadata (par défaut), seuls la structure, les durées, l'usage et les coûts quittent la machine. En content, le contenu sort aussi, masqué selon les motifs déclarés. Pour le débogage, capture: {raw_exchanges: true} ajoute au journal chaque échange HTTP avec le fournisseur. Les secrets et les octets des fichiers en sont retirés.
Rejeu. Un run se rejoue de deux façons :
- À l'identique, avec
loom replay <run_id>ouLoom.replay(). Le rejeu se fait en mémoire, sans aucun appel réseau : les réponses des modèles et les résultats des outils sont lus dans le journal, et la logique de Loom-IA tourne avec la configuration d'aujourd'hui. Si quelque chose a changé (un prompt, un outil, une politique), le rejeu s'arrête sur la première divergence et dit quelle partie de la requête a bougé. - En variante, avec
--mode variant. Le rejeu peut alors utiliser un autre modèle par étape (--model main=HAIKU) ou une autre configuration. Ce que le journal connaît est servi ; le reste part pour de vrai. Un outil à effet de bord n'est jamais réexécuté : il est lu dans le journal, remplacé par une doublure (--double) ou refusé.
Les codes de sortie sont 0, 1 ou 2, selon que le run se rejoue, diverge ou ne peut pas être rejoué.
Évaluations. Une suite décrit des cas et ce qu'on en attend :
version: 1
config: loom.yaml
agent: devis
repeat: 2
variants:
- {name: actuel}
- {name: haiku, models: {main: HAIKU}}
cases:
- name: tva-reduite
input: Quel prix TTC pour 1 250 € HT en rénovation (TVA à 10 %) ?
expect:
status: completed
contains: ["1 375"]
called:
- {name: prix_ttc, arguments: {montant_ht: 1250, taux_tva: 10}}
loom eval suite.yaml joue chaque cas pour chaque variante, dans un journal temporaire, et rend un rapport qui compare les variantes cas par cas. Les outils à effet de bord y sont doublés ou refusés, jamais exécutés. Une section judge ajoute des critères notés par un modèle, et max_cost_usd plafonne la dépense de la suite.
Non-régression. Un journal exporté (loom sessions export) devient un test. loom replay --journal fichier.jsonl le rejoue avec la configuration d'aujourd'hui. Un cas replay: fait de même dans une suite d'évaluations, et loom_ia.testing.assert_replays() dans un test pytest.
Kit de test. Bench met un agent au banc, isolé de ses données réelles :
from loom_ia.testing import Bench
async def test_le_ttc_passe_par_l_outil() -> None:
async with Bench("loom.yaml") as banc:
result = await banc.run("devis", "Quel prix TTC pour 1 250 € HT à 10 % ?")
banc.expect(
result,
status="completed",
contains=["1 375"],
called=[{"name": "prix_ttc", "arguments": {"montant_ht": 1250, "taux_tva": 10}}],
)
Les modèles s'y remplacent par des ScriptedModel, les outils par des doublures. Un modèle réel non remplacé est refusé, sauf real_models=True, pour qu'un test ne dépense rien par mégarde, et un outil à effet de bord sans doublure n'est jamais exécuté.
Logs. Les logs techniques restent séparés des traces. En niveau INFO, Loom-IA écrit une ligne par appel de modèle et d'outil, sans aucun contenu. Chaque ligne porte run_id, span_id et tenant_id, au format console ou JSON.
Les accès en détail
API Python (loom_ia.access.Loom) :
| Méthode | Rôle |
|---|---|
Loom.from_config(chemin), Loom(config) |
Charger une configuration |
run(), stream(), submit() |
Lancer un run : attendre, suivre en direct, ou mettre en arrière-plan |
result(), state(), events(), follow() |
Relire un run, suivre son journal |
cancel(), resume(), recover() |
Arrêter, reprendre, remettre en file les runs en plan |
approve(), reject() |
Trancher une demande d'approbation |
session(), sessions(), export_session(), delete_session() |
Gérer les conversations |
runs(), query(), trace() |
Lister les runs, chercher dans le journal, lire une trace |
report(), consumption() |
Consommation d'un run, d'une session, d'un client |
replay(), replay_journal(), evaluate() |
Rejeu et évaluations |
trigger() |
Ouvrir un run par une porte déclarée |
register() |
Rendre un objet Python référençable depuis la configuration |
API REST (loom serve, ou create_app(loom) de loom_ia.access.http pour l'intégrer à une application FastAPI existante) :
| Méthode | Route | Rôle | Portée |
|---|---|---|---|
GET |
/v1/agents |
Lister les agents | read |
POST |
/v1/agents/{name}/runs |
Lancer un run (JSON ou multipart avec pièces jointes) | run |
GET |
/v1/runs |
Lister les runs du client | read |
GET |
/v1/runs/{id} |
Statut et résultat | read |
GET |
/v1/runs/{id}/events |
Déroulé en SSE, reprise par Last-Event-ID |
read |
POST |
/v1/runs/{id}/approve · /reject |
Validation humaine | approve |
POST |
/v1/runs/{id}/cancel |
Arrêt | run |
GET |
/v1/sessions · /v1/sessions/{id} |
Sessions, fiche d'une session | read |
GET |
/v1/sessions/{id}/events |
Journal d'une session en JSONL | read |
GET |
/v1/sessions/{id}/report |
Consommation d'une session | read |
DELETE |
/v1/sessions/{id} |
Effacement RGPD | admin |
GET |
/v1/events |
Recherche dans le journal | read |
GET |
/v1/traces/{run_id} |
Trace d'un run | read |
POST |
/v1/hooks/{nom} |
Webhook entrant | run |
Le document OpenAPI est généré, ce qui permet de générer le client d'une interface.
Serveur MCP (loom mcp en stdio, ou en HTTP dans l'application de loom serve) :
- chaque agent publié devient un outil, qui prend un
message, unsession_idet des pièces jointes : image en base64, lienartifact://, ou lienfile://sous les dossiers autorisés parserver.mcp.file_roots; - trois outils de contrôle s'y ajoutent :
run_status,run_reportetcancel; - le journal se lit en ressources
loom://: runs, sessions, événements, traces, fichiers ; - si le client fournit un jeton de progression, le déroulé du run lui arrive en notifications.
En stdio, un serveur sert un seul client, choisi avec --tenant.
expose: {rest: false, mcp: true} choisit, agent par agent, sur quels accès il est publié.
Ligne de commande :
| Commande | Rôle |
|---|---|
loom validate |
Vérifier la configuration et monter les agents |
loom run <agent> "<message>" |
Lancer un run (--stream, --session, --attach, --json) |
loom resume <run_id> |
Reprendre un run interrompu |
loom approve · loom reject <run_id> |
Trancher une demande d'approbation |
loom inspect <run_id> |
Afficher l'arbre d'un run |
loom report |
Consommation d'un run, d'une session ou d'un client |
loom replay <run_id> |
Rejouer un run, à l'identique ou en variante |
loom eval <suite.yaml> |
Jouer une suite d'évaluations |
loom serve |
Servir l'API REST (--reload en développement) |
loom mcp |
Servir les agents en MCP sur stdio |
loom worker |
Consommer la file de tâches |
loom sessions list · export · delete |
Gérer les sessions |
loom keys create <id> |
Fabriquer une clé d'API |
loom retention |
Effacer les sessions trop anciennes |
loom storage sql |
Imprimer le SQL du stockage Postgres |
loom schema |
Imprimer le JSON Schema de la configuration |
Les options globales --config (par défaut loom.yaml) et --profile se placent avant la commande.
Les stockages
| Stockage | Backends | Par défaut |
|---|---|---|
Journal (storage.events) |
memory, jsonl, sqlite, postgres |
memory |
Fichiers (storage.artifacts) |
local, memory |
suit le journal |
Idempotence (storage.idempotency) |
journal, memory, sqlite, postgres, redis |
journal |
File de tâches (storage.queue) |
asyncio, rabbitmq |
asyncio |
Bus entre process (storage.bus) |
memory, postgres, redis |
memory |
Pour une base de données, la configuration ne contient que le nom de la variable qui porte l'adresse de connexion (dsn_env, url_env).
Quelques repères pour choisir :
- En local, un journal JSONL suffit. Les fichiers sont alors rangés à côté du journal, dans
.artifacts. - En service, Postgres tient le journal et l'idempotence, Redis ou Postgres le bus, RabbitMQ la file.
- Avec un journal en mémoire, rien ne survit au process. Les agents qui peuvent se mettre en pause sont refusés, sauf en profil
dev.
Développer et tester
git clone https://github.com/denislamard/loom_v2.git
cd loom_v2
uv sync --all-extras --group types
Les contrôles sont ceux de la CI :
uv run pytest # la suite de tests
uv run ruff check . # lint
uv run ruff format --check . # format
uv run pyright # typage strict
uv run lint-imports # règles de dépendances entre couches
Les tests qui demandent un service réel (Postgres, RabbitMQ, Redis) lisent son adresse dans une variable d'environnement, et sont sautés s'il n'est pas là :
export LOOM_TEST_POSTGRES=postgresql://loom:loom@127.0.0.1:5432/loom
export LOOM_TEST_RABBITMQ=amqp://loom:loom@127.0.0.1:5672/
export LOOM_TEST_REDIS=redis://127.0.0.1:6379/1
uv run pytest --require-services
Avec --require-services, un service absent fait échouer le test au lieu de le sauter, pour qu'une suite verte prouve vraiment quelque chose. Le rôle Postgres ne doit pas être superutilisateur, puisqu'un superutilisateur contourne la sécurité au niveau des lignes. Il lui faut en revanche le droit CREATEROLE, car le stockage crée son rôle applicatif à la première requête.
La CI, sur GitHub Actions, compte quatre jobs :
- la qualité : ruff, pyright et les contrats d'import ;
- les tests du noyau seul, sans aucun extra, pour vérifier que le noyau s'importe sans SDK ;
- les tests avec tous les extras, Postgres 16, RabbitMQ 3.12 et Redis 7 en conteneurs, et la couverture ;
- la construction du paquet.
Les exemples de examples/ servent aussi de recette : la plupart tournent en simulé, et presque tous acceptent --reel pour un passage avec de vrais modèles.
Licence
Loom-IA est distribué sous licence Apache 2.0.
Metadata
Release files for loom-ia 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| loom_ia-2.0.0.tar.gz | 513.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| loom_ia-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.1 MB
Release files / loom_ia-2.0.0.tar.gz
| Download URL | loom_ia-2.0.0.tar.gz |
|---|---|
| Size | 513.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b1200ac4bdcdf990fd6cb863822ee7e31de4911bbc157e727d1d36e5179bd0ca
|
|
BLAKE2b-256 checksum How to use checksums |
e9de1dfe936b72bfad43f348969b53864c7edabe93ffac7d094784401346056c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / loom_ia-2.0.0-py3-none-any.whl
| Download URL | loom_ia-2.0.0-py3-none-any.whl |
|---|---|
| Size | 604.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2cbd9a537c79f251fcbbdc43e11982373d6c35cd8dfdb84119540a5704e6a815
|
|
BLAKE2b-256 checksum How to use checksums |
4278b240458d56e5e2d07d9cdc1499590d5330f53dc245f69cc662dedf480a7c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log