Le premier agent que vous livrez est toujours un script. Le deuxième agent est une copie du premier avec d'autres outils. Le troisième agent, c'est là que les choses dérapent : quelqu'un forke l'un des scripts précédents, les dépendances divergent, la journalisation devient incohérente, et un trimestre plus tard vous avez cinq agents dans quatre dépôts sans aucun code partagé.
Si vous savez que vous allez construire plus d'un agent, mettez en place le monorepo avant de construire le deuxième. Pas avant le premier. La structure prématurée a un coût réel, et je ne vais pas prétendre le contraire. Mais la fenêtre entre l'agent un et l'agent deux est celle où vous payez le moins et économisez le plus.
L'hypothèse sur la stack
Cet article suppose Angular sur le frontend, Python pour le runtime de l'agent, Docker pour le dev local. C'est la stack par défaut que j'utilise pour mes propres projets. Si vous êtes Node/TypeScript de bout en bout, la majeure partie se traduit directement. Si vous êtes Go ou Rust côté backend, la structure est la même, les choix d'outillage diffèrent.
Pourquoi Python pour l'agent lui-même. Les SDK IA (Anthropic, OpenAI) sont plus matures en Python, l'écosystème d'outils autour des vector stores et des pipelines d'embedding est Python-first, et une boucle ReAct se lit mieux dans un langage où un outil n'est qu'une fonction et un dict n'est qu'un dict. Je garde le frontend en Angular parce que c'est là que je construis chaque produit, et le tableau de bord pour observer les exécutions d'agents est une vraie application Angular.
La structure
my-agents/
├── apps/
│ ├── dashboard/ # Angular app, agent run viewer
│ └── api/ # Python FastAPI, dashboard backend
├── agents/
│ ├── sonar_bridge/ # First agent
│ │ ├── agent.py
│ │ ├── tools.py
│ │ ├── playbook.py
│ │ └── tests/
│ ├── docs_drift/ # Second agent
│ └── bug_triage/ # Third agent
├── libs/
│ ├── base_agent/ # The shared ReAct loop
│ ├── memory/ # pgvector client, Postgres schema, Redis client
│ ├── tools/ # Reusable tools: github, slack, sonar, jira
│ └── telemetry/ # Logging, cost tracking, evaluation
├── infra/
│ ├── docker-compose.yml # postgres, redis, sonarqube for local dev
│ ├── migrations/ # Alembic for Postgres schema
│ └── k8s/ # deployment manifests if you need them
├── knowledge/ # Markdown knowledge base (Obsidian-compatible)
│ ├── conventions/
│ ├── runbooks/
│ └── decisions/
├── pyproject.toml # Workspace root, points to libs and agents
├── package.json # For the dashboard
└── docker-compose.ymlTrois zones. apps/ contient le code orienté utilisateur : le tableau de bord et son API. agents/ contient les agents individuels, chacun étant un petit paquet. libs/ est le code partagé dont dépend chaque agent. Rien d'autre n'a sa place à ce niveau.
L'agent de base
libs/base_agent/ est la pièce la plus importante de toute la structure. Il contient la boucle ReAct, les patterns d'accès mémoire, le suivi des coûts et la télémétrie. Chaque agent dans agents/ importe depuis celui-ci et définit uniquement ce qui est spécifique à son domaine : le playbook (system prompt), la liste d'outils, les règles d'escalade.
# libs/base_agent/agent.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable
from libs.memory import MemoryStack
from libs.telemetry import Telemetry
@dataclass
class AgentConfig:
name: str
version: str
playbook: str
tools: list[Callable]
confidence_threshold: float = 0.7
max_iterations: int = 20
scratchpad_window: int = 6
class BaseAgent(ABC):
"""Owns the loop. The three hooks below are what each agent supplies."""
def __init__(self, config: AgentConfig, memory: MemoryStack, telemetry: Telemetry):
self.config = config
self.memory = memory
self.telemetry = telemetry
@abstractmethod
def _plan(self, trigger: dict, context: str | None,
scratchpad: list) -> tuple[str, dict | None]:
"""One model call. Returns (thought, action), or action=None when done."""
@abstractmethod
def _execute(self, action: dict) -> dict:
"""Run one tool from config.tools and return the observation."""
@abstractmethod
def _decide(self, scratchpad: list) -> dict:
"""Turn the finished scratchpad into the run outcome."""
def run(self, trigger: dict) -> dict:
run_id = self.telemetry.start_run(self.config, trigger)
similar = self.memory.vector_search(trigger, top_k=5)
context = self.memory.summarize(similar) if similar else None
scratchpad = []
for iteration in range(self.config.max_iterations):
thought, action = self._plan(trigger, context, scratchpad)
if action is None:
break
observation = self._execute(action)
scratchpad.append((thought, action, observation))
# The scratchpad is what blows the context budget on a long
# run. Compress the older entries instead of carrying 20
# raw observations into the next plan call.
if len(scratchpad) > self.config.scratchpad_window:
scratchpad = self.memory.compress(
scratchpad, keep_last=self.config.scratchpad_window
)
outcome = self._decide(scratchpad)
self.telemetry.end_run(run_id, outcome)
self.memory.write(run_id, outcome)
return outcomeBaseAgent possède la boucle et rien d'autre. _plan, _execute et _decide sont abstraites volontairement : _plan est l'appel au modèle, _execute est le dispatch d'outils, _decide transforme le scratchpad terminé en résultat. L'implémentation concrète de ces trois-là vit une seule fois dans libs/base_agent/, donc un nouvel agent est un objet de configuration et un playbook, environ 30 lignes. Notez l'étape de compression à l'intérieur de la boucle. Vingt itérations de thought, action et observation brutes, c'est comme ça que vous vous retrouvez avec une facture de tokens que vous n'aviez pas prévue.
# agents/sonar_bridge/agent.py
from libs.base_agent import ReActAgent, AgentConfig
from libs.memory import MemoryStack
from libs.telemetry import Telemetry
from libs.tools import sonar, github
from .playbook import PLAYBOOK
def build() -> ReActAgent:
return ReActAgent(
config=AgentConfig(
name="sonar_bridge",
version="0.1.0",
playbook=PLAYBOOK,
tools=[
sonar.get_issues_for_pr,
github.read_file_range,
github.comment_on_pr,
],
confidence_threshold=0.85,
),
memory=MemoryStack.default(),
telemetry=Telemetry.default(),
)Le tableau de bord compte vraiment
Angular fait du vrai travail ici. Il vous faut un endroit pour voir chaque exécution d'agent, filtrer par résultat, comparer les décisions entre versions et rejouer les échecs. Ce n'est pas un nice-to-have, c'est comme ça que vous déboguez un agent qui a décidé de faire une escalade trois fois de suite la nuit dernière.
L'API Python dans apps/api/ est un wrapper FastAPI léger qui lit depuis le même Postgres dans lequel les agents écrivent. Pas de magie, pas de duplication. Le tableau de bord est un client idiot des mêmes données que vos agents produisent déjà.
Dev local en une seule commande
# docker-compose.yml at the root
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: local
POSTGRES_DB: agents
ports: ["5432:5432"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
sonarqube:
image: sonarqube:community
ports: ["9000:9000"]
api:
build: ./apps/api
depends_on: [postgres, redis]
environment:
DATABASE_URL: postgres://postgres:local@postgres/agents
REDIS_URL: redis://redis:6379
ports: ["8000:8000"]
dashboard:
build: ./apps/dashboard
depends_on: [api]
ports: ["4200:4200"]docker-compose up donne à quiconque clone le dépôt un environnement de travail complet : Postgres avec pgvector, Redis, SonarQube, l'API et le tableau de bord Angular. Pas besoin de bidouiller les installations locales de Postgres, pas de 'ça marche sur ma machine' dès le premier jour.
Le répertoire de connaissances
knowledge/ est un dossier de fichiers markdown. Pas une fonctionnalité. Pas un plugin. Juste du markdown. L'équipe l'édite directement, ou pointe un vault Obsidian dessus. Un indexeur dans libs/memory/ synchronise le contenu dans pgvector à chaque changement, et chaque agent qui a besoin d'un playbook ou d'une convention lit depuis la même source.
Je sépare délibérément la base de connaissances du code. La connaissance métier évolue indépendamment du code de l'agent. Vous voulez que des non-ingénieurs éditent knowledge/ sans toucher à agents/. Git versionne toujours. Les revues continuent d'avoir lieu. Mais la frontière est claire.
Ce que cela vous apporte
- Nouvel agent = nouveau dossier dans agents/, 30 lignes de Python. Pas de débat sur l'infrastructure.
- Chaque agent produit la même forme de télémétrie. Le tableau de bord n'a pas besoin de code par agent.
- La mémoire, les outils et la logique d'évaluation sont partagés. Un correctif dans libs/ corrige chaque agent d'un coup.
- Le dev local tient en une commande. Du clone à la stack qui tourne, sans document de setup à suivre.
- Le déploiement en production utilise le même pattern d'image de conteneur pour chaque agent. Un pipeline, plusieurs agents.
Où je trace la ligne
Tout n'appartient pas au monorepo. Si un agent a un rayon d'impact différent, disons qu'il peut écrire dans un système de production alors que les autres ne font que commenter des pull requests, il obtient son propre dépôt et sa propre cadence de release. Si un agent est expérimental et que je ne sais pas s'il survivra au mois, je le prototype à l'extérieur et je l'intègre une fois qu'il a prouvé sa valeur.
Le monorepo est pour les agents de production. Les stables. Ceux dont votre équipe dépend. Tout le reste peut commencer comme un script et être promu une fois qu'il a gagné sa place.
Un agent de base partagé et une couche de télémétrie cohérente valent plus que n'importe quel agent individuel. C'est ce code qui rend le cinquième agent facile à livrer.
Références
- Outillage monorepo Nx (si vous voulez le formaliser) : https://nx.dev
- pgvector : https://github.com/pgvector/pgvector
- FastAPI : https://fastapi.tiangolo.com
- SDK Python Anthropic : https://github.com/anthropics/anthropic-sdk-python