Aller au contenu
Se connecter

Certifications

Série · Certification Claude Certified ArchitectÉpisode 04 / 20

Claude Architect, épisode 4 : un coordinateur et deux sous-agents, en Python

Le projet qui assemble les épisodes 1 à 3 : un remboursement traité par un coordinateur et deux sous-agents, avec la porte en code et les contrôles d'outil que l'examen attend.

Par 16 min de lecture

Trois épisodes de concepts, et maintenant un vrai programme. Un client demande le remboursement d’une commande. Un coordinateur découpe la demande, un premier sous-agent vérifie le client, un second traite le remboursement, et le coordinateur assemble la réponse.

Le scénario est simple, et c’est voulu : il suffit pour tomber dans les deux pièges que l’examen adore. Le premier est un « résultat structuré » qui n’est que de la prose dans une enveloppe. Le second est une règle métier confiée au modèle alors qu’elle devait être écrite en code.

Où on en est dans le programme

Domaine 1 — Architecture agentique et orchestration (27 % de l'examen). Cet épisode met en pratique :

  • 1.1 — la boucle agentique, ici avec ses garde-fous ;
  • 1.2 et 1.3 — le coordinateur, ses sous-agents et le contexte transmis ;
  • 1.4 — un workflow en plusieurs étapes, avec contrôles et passage de relais.

Objectifs de l’épisode

À la fin, vous saurez :

  1. assembler une boucle agentique, des sous-agents et un coordinateur dans un seul programme ;
  2. donner à chaque sous-agent uniquement les outils de son rôle ;
  3. construire un constat structuré à partir des résultats d’outils, pas de la prose du modèle ;
  4. placer une porte en code entre deux étapes ;
  5. faire vérifier les règles métier par l’outil lui-même ;
  6. distinguer un résultat vide d’une vraie erreur d’outil ;
  7. relier ce code « à la main » à l’outil Task (ou Agent) de l’Agent SDK.

Prérequis : les épisodes 1, 2 et 3.

1. Le scénario

Le client C001 demande le remboursement de la commande 0100. Voici le trajet de la demande :

Le coordinateur découpe la demande, le vérificateur appelle get_customer, une porte en code vérifie le constat, le sous-agent de remboursement traite la commande, puis le coordinateur agrège. Si le client n'est pas vérifié, la porte arrête tout avec le statut rejected. Coordinateur découpe Vérificateur get_customer Porte en code Remboursement 2 outils Agrégation statut + résumé client non vérifié arrêt : status « rejected »
Chaque sous-agent fait tourner sa propre boucle agentique. La porte, elle, est du code ordinaire : aucun appel au modèle.

Chaque concept des épisodes précédents a sa place :

Concept Où il apparaît Épisode
boucle agentique chaque sous-agent tourne jusqu’à end_turn 1
décomposition le coordinateur crée deux sous-tâches 2
isolation du contexte le vérificateur ne reçoit que l’identifiant du client 2
passage de contexte structuré le constat est transmis en JSON 3
agrégation le coordinateur produit un statut et un résumé 2
contrôle entre étapes la porte et les vérifications de l’outil 4

2. Les outils et leurs données

Pour se concentrer sur l’orchestration, les données sont factices. Les outils restent du code ordinaire : Claude peut seulement demander à les appeler.

python
import json
from functools import partial

import anthropic

client = anthropic.Anthropic()        # lit la clé dans ANTHROPIC_API_KEY
MODEL = "claude-haiku-4-5"
MAX_ITERATIONS = 10                   # soupape de sécurité, pas la condition d'arrêt

# --- Fausses données : aucune vraie API n'est appelée ---
CLIENTS = {
    "C001": {"name": "Alice", "email": "alice@example.com", "plan": "pro"},
    "C002": {"name": "Bob", "email": "bob@example.com", "plan": "free"},
}
COMMANDES = {
    "0100": {"customer_id": "C001", "item": "Annual Plan", "amount": 99.00, "status": "delivered"},
    "0101": {"customer_id": "C002", "item": "Monthly Plan", "amount": 9.00, "status": "refunded"},
}


# --- Les outils : du code ordinaire, que Claude ne peut que demander ---
def get_customer(customer_id: str) -> dict:
    fiche = CLIENTS.get(customer_id)
    if fiche:
        return {"found": True, "customer_id": customer_id, **fiche}
    return {"found": False, "customer_id": customer_id}   # résultat valide, pas une erreur


def lookup_order(order_id: str) -> dict:
    commande = COMMANDES.get(order_id)
    if commande:
        return {"found": True, "order_id": order_id, **commande}
    return {"found": False, "order_id": order_id}


def process_refund(order_id: str, amount: float, *, client_verifie: str) -> dict:
    # client_verifie est fixé par le coordinateur, jamais par le modèle.
    commande = COMMANDES.get(order_id)
    if not commande:
        return {"ok": False, "reason": f"no order {order_id}"}
    if commande["customer_id"] != client_verifie:
        return {"ok": False, "reason": "order does not belong to the verified customer"}
    if commande["status"] == "refunded":
        return {"ok": False, "reason": "order already refunded"}
    if amount > commande["amount"]:
        return {"ok": False, "reason": "amount exceeds the order total"}
    commande["status"] = "refunded"
    return {"ok": True, "order_id": order_id, "refunded": amount}

Regardez process_refund : c’est lui qui vérifie que la commande appartient au client, qu’elle n’est pas déjà remboursée et que le montant est cohérent. Une description d’outil qui dit « n’appelez ceci qu’après avoir vérifié le client » est une consigne. Le modèle la suit presque toujours, mais presque toujours ne suffit pas pour de l’argent.

Le paramètre client_verifie n’apparaît pas dans le schéma envoyé à Claude. Le modèle ne peut donc pas l’inventer : c’est le coordinateur qui le fixe (section 5).

Chaque sous-agent ne reçoit que les outils de son rôle :

Sous-agent get_customer lookup_order process_refund
Vérificateur oui non non
Remboursement non oui oui

Les schémas, eux, sont ce que Claude sait des outils : un nom, une description et des paramètres.

python
# --- Les schémas envoyés à l'API : ce que Claude sait des outils ---
SCHEMAS = {
    "get_customer": {
        "name": "get_customer",
        "description": "Fetch a customer record by ID. Returns found, name, email and plan.",
        "input_schema": {
            "type": "object",
            "properties": {"customer_id": {"type": "string", "description": "e.g. C001"}},
            "required": ["customer_id"],
        },
    },
    "lookup_order": {
        "name": "lookup_order",
        "description": "Fetch an order by ID. Returns item, amount, status and the linked customer ID.",
        "input_schema": {
            "type": "object",
            "properties": {"order_id": {"type": "string", "description": "e.g. 0100"}},
            "required": ["order_id"],
        },
    },
    "process_refund": {
        "name": "process_refund",
        "description": "Refund an order for the verified customer. Fails if the order belongs "
                       "to someone else, is already refunded, or the amount is too high.",
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string"},
                "amount": {"type": "number", "description": "Amount to refund, in USD"},
            },
            "required": ["order_id", "amount"],
        },
    },
}

3. La boucle de chaque sous-agent

C’est la boucle de l’épisode 1, avec trois garde-fous qu’on oublie facilement dans un projet :

Un arrêt qui n'est ni end_turn ni tool_use. Avec max_tokens ou refusal, une boucle qui ne teste que ces deux valeurs renvoie la même requête à l'infini. Ici, elle s'arrête avec une erreur explicite.

Un outil hors du rôle. Si le modèle demande un outil qui n'est pas dans le registre du sous-agent, la boucle ne plante pas : elle renvoie un tool_result en erreur, et Claude peut se corriger.

Une vraie erreur, et seulement elle. « Client introuvable » est un résultat valide : found: false, sans is_error. Seule une exception (outil cassé, accès refusé) est marquée is_error: true.

python
# --- Épisode 1 : la boucle agentique, avec ses garde-fous ---
def run_agentic_loop(system_prompt: str, user_message: str, schemas: list, fonctions: dict):
    """Renvoie le texte final de Claude ET le journal des appels d'outils."""
    messages = [{"role": "user", "content": user_message}]
    journal = []
    for _ in range(MAX_ITERATIONS):
        response = client.messages.create(
            model=MODEL, max_tokens=1024, system=system_prompt,
            tools=schemas, messages=messages,
        )
        if response.stop_reason == "end_turn":
            texte = "".join(b.text for b in response.content if b.type == "text")
            return texte, journal
        if response.stop_reason != "tool_use":
            # max_tokens, refusal… : s'arrêter plutôt que renvoyer la même requête
            raise RuntimeError(f"arrêt inattendu : {response.stop_reason}")

        messages.append({"role": "assistant", "content": response.content})
        resultats = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            fonction = fonctions.get(block.name)
            try:
                if fonction is None:
                    raise PermissionError(f"tool {block.name} is not allowed here")
                sortie, en_erreur = fonction(**block.input), False
            except Exception as exc:          # l'outil a échoué : on le dit à Claude
                sortie, en_erreur = {"error": str(exc)}, True
            journal.append({"tool": block.name, "input": block.input, "output": sortie})
            resultats.append({
                "type": "tool_result",
                "tool_use_id": block.id,      # doit correspondre au bloc tool_use
                "content": json.dumps(sortie),
                "is_error": en_erreur,
            })
        messages.append({"role": "user", "content": resultats})
    raise RuntimeError("MAX_ITERATIONS atteint")

La boucle renvoie aussi un journal des appels d’outils. C’est lui qui va servir à construire des constats fiables.

4. Le sous-agent : la même boucle, un rôle étroit

Un sous-agent n’a rien de spécial : c’est une boucle agentique avec une consigne système centrée sur un rôle, et des outils limités.

python
# --- Épisode 2 : un sous-agent, c'est la même boucle avec un rôle étroit ---
def run_subagent(role: str, task_prompt: str, schemas: list, fonctions: dict) -> dict:
    system_prompt = (
        f"You are the {role} subagent in a customer support system. "
        "Use only your tools. Be concise. Never make up data."
    )
    texte, journal = run_agentic_loop(system_prompt, task_prompt, schemas, fonctions)
    return {"role": role, "summary": texte, "tool_calls": journal}


def dernier_resultat(sous_agent: dict, outil: str):
    sorties = [c["output"] for c in sous_agent["tool_calls"] if c["tool"] == outil]
    return sorties[-1] if sorties else None
Le piège de l'enveloppe

Mettre la réponse du modèle dans un dictionnaire, par exemple {"role": …, "result": texte, "status": "complete"}, ne la rend pas structurée. Le champ result reste de la prose, et status dit « complete » même quand le client n'existe pas. Un constat est structuré quand ses champs portent les faits eux-mêmes, et que du code peut les tester.

5. Le coordinateur : découper, contrôler, transmettre, agréger

C’est ici que tout se joue. Le constat est construit à partir du résultat de l’outil, une porte bloque la suite si le client n’est pas vérifié, et le constat est transmis au second sous-agent en JSON.

python
# --- Le coordinateur : décomposer, contrôler, transmettre, agréger ---
def coordinator(customer_id: str, order_id: str) -> dict:
    # Sous-tâche 1 : le vérificateur ne reçoit que l'identifiant du client.
    verification = run_subagent(
        "Customer Verifier",
        f"Look up customer {customer_id} and confirm they exist. Return their name and plan.",
        [SCHEMAS["get_customer"]],
        {"get_customer": get_customer},
    )

    # Épisode 3 : le constat structuré vient du résultat de l'outil, pas de la prose.
    fiche = dernier_resultat(verification, "get_customer") or {}
    constat = {
        "customer_id": customer_id,
        "verified": fiche.get("found") is True,
        "plan": fiche.get("plan"),
    }

    # La porte : sans client vérifié, le remboursement ne démarre jamais.
    if not constat["verified"]:
        return {"status": "rejected", "reason": "customer not verified", "verification": constat}

    # Sous-tâche 2 : le constat est passé explicitement, en JSON.
    remboursement = run_subagent(
        "Refund Processor",
        f"Process a refund for order {order_id}.\n\n"
        f"PRIOR VERIFICATION (structured):\n{json.dumps(constat, indent=2)}\n\n"
        "Steps: 1. Look up the order. 2. Check it belongs to the verified customer. "
        "3. If so, refund the full order amount. Otherwise, explain why not.",
        [SCHEMAS["lookup_order"], SCHEMAS["process_refund"]],
        {
            "lookup_order": lookup_order,
            "process_refund": partial(process_refund, client_verifie=customer_id),
        },
    )

    # Agrégation : un statut lisible par du code, et le résumé pour un humain.
    refund = dernier_resultat(remboursement, "process_refund") or {}
    return {
        "status": "refunded" if refund.get("ok") else "not_refunded",
        "verification": constat,
        "refund": refund,
        "summary": remboursement["summary"],
    }


if __name__ == "__main__":
    for customer_id, order_id in [("C001", "0100"), ("C999", "0100"), ("C002", "0101")]:
        print(f"\n=== {customer_id} / commande {order_id}")
        print(json.dumps(coordinator(customer_id, order_id), indent=2, ensure_ascii=False))

Mis bout à bout, ces cinq blocs forment le programme complet. Il faut pip install anthropic et une clé dans ANTHROPIC_API_KEY.

Trois détails font la différence :

  • Si le vérificateur n’appelle jamais get_customer, fiche est vide et verified vaut False. Par défaut, on refuse.
  • partial(process_refund, client_verifie=customer_id) attache le client vérifié à l’outil. Le modèle choisit la commande et le montant, jamais le client.
  • Le statut final vient du résultat de process_refund, pas du résumé du modèle. Un modèle qui écrit « remboursement effectué » alors que l’outil a refusé ne trompe pas le coordinateur.

6. Trois niveaux de contrôle

Ce projet empile trois protections. Seules les deux dernières sont des garanties :

Trois niveaux de contrôle : la consigne dans la description de l'outil, que le modèle peut ignorer ; la porte dans le coordinateur, en code ; la vérification dans l'outil, en code, qui protège même si le reste échoue. 1 · Consigne description d'outil, prompt système le modèle la suit… presque toujours une probabilité, pas une garantie 2 · Porte if dans le coordinateur bloque l'étape suivante aucun sous-agent lancé pour rien 3 · Contrôle dans l'outil process_refund vérifie tout dernière ligne de défense tient même si le reste échoue
Une règle dont dépend de l'argent, des données ou la sécurité se met en code. La consigne aide le modèle à bien faire, elle ne l'en empêche pas de mal faire.

Sans porte, rien n’empêche le second sous-agent de démarrer après un client introuvable. Il reçoit alors un constat négatif et doit décider seul de refuser. Il le fera probablement, mais vous payez un appel inutile et vous confiez la décision au modèle.

L’Agent SDK offre un endroit dédié pour ce genre de contrôle : les hooks, qui interceptent un appel d’outil avant son exécution. Ce sera la tâche 1.5, dans un prochain épisode.

7. Ce que donnent les trois cas

Cas Ce qui se passe Statut final
C001, commande 0100 client vérifié, commande à lui, livrée : remboursement de 99,00 refunded
C999, commande 0100 get_customer renvoie found: false : la porte arrête tout, un seul sous-agent lancé rejected
C002, commande 0101 client vérifié, mais commande déjà remboursée : l’outil refuse not_refunded

Les statuts sont garantis par le code, quel que soit le texte du modèle. Les résumés, eux, changent d’une exécution à l’autre.

Pour tester d’autres scénarios, ajoutez des clients et des commandes. Essayez par exemple la commande 0101 avec le client C001 : la commande appartient à C002, et process_refund refusera même si le modèle tente le remboursement.

Bon à savoir : et l'outil Task ?

Ce programme lance ses sous-agents avec de simples fonctions Python : l'API Messages n'a pas d'outil de lancement de sous-agents. L'outil Task que cite l'examen appartient à Claude Code et à l'Agent SDK, où la documentation actuelle l'appelle Agent.

L'architecture reste la même : un coordinateur, des sous-agents au contexte isolé et aux outils limités, et un contexte transmis explicitement. Pour l'examen, ce sont ces principes qui comptent, pas le mécanisme de lancement.

Dans l’Agent SDK, les deux sous-agents se déclareraient à l’avance, comme dans l’épisode 2. Les outils maison passent par un serveur MCP, d’où leurs noms préfixés. Vérifiez ces noms dans la documentation de votre version :

python
from claude_agent_sdk import ClaudeAgentOptions, AgentDefinition

options = ClaudeAgentOptions(
    allowed_tools=["Agent"],          # "Task" dans les anciennes versions
    agents={
        "customer-verifier": AgentDefinition(
            description="Verifies that a customer exists. Use before any refund.",
            prompt="Look up the customer and report found, name and plan. Never make up data.",
            tools=["mcp__support__get_customer"],
            model="haiku",
        ),
        "refund-processor": AgentDefinition(
            description="Processes a refund for an already verified customer.",
            prompt="Check the order belongs to the verified customer, then refund it.",
            tools=["mcp__support__lookup_order", "mcp__support__process_refund"],
            model="haiku",
        ),
    },
)

8. Les anti-patterns de l’examen

Anti-pattern Pourquoi c’est faux La bonne approche
renforcer le prompt pour qu’un outil sensible soit appelé au bon moment une consigne reste une probabilité une porte ou un contrôle en code
emballer la prose du modèle dans un dictionnaire les faits restent illisibles pour le code des champs tirés des résultats d’outils
donner tous les outils à tous les sous-agents un sous-agent peut sortir de son rôle seulement les outils du rôle
laisser le modèle fournir l’identité vérifiée il peut se tromper ou l’inventer le coordinateur la fixe dans l’outil
marquer « introuvable » comme une erreur Claude croit à une panne et réessaie un résultat valide, sans is_error
lire le statut final dans le résumé du modèle le texte peut contredire les faits le statut vient des résultats d’outils

9. Deux scénarios d’examen

Scénario 1. Un agent de support appelle parfois process_refund sans avoir appelé get_customer. L’équipe propose d’ajouter « TOUJOURS vérifier le client avant un remboursement » en majuscules dans le prompt système. Le problème : une consigne plus forte réduit la fréquence, mais n’élimine pas le cas. La correction : rendre la vérification obligatoire en code. process_refund est bloqué tant que get_customer n’a pas renvoyé un client vérifié, par une porte dans l’orchestration ou par un hook.

Scénario 2. Le sous-agent de remboursement rembourse parfois des commandes d’un autre client. Le coordinateur lui transmet : « Le vérificateur dit que le client semble correct. » Le problème : une conclusion vague en prose, sans identifiant ni champ testable. La correction : transmettre un constat structuré (customer_id, verified, plan), et faire vérifier l’appartenance de la commande par process_refund lui-même.

Variante piège

Si le scénario 1 dit que l'erreur arrive « dans 0,1 % des cas, sans conséquence financière », la réponse attendue peut changer. Une consigne plus précise ou un exemple ajouté au prompt peut alors suffire. Le code devient indispensable quand l'erreur coûte de l'argent, expose des données ou touche à la sécurité.

Fiche de révision

Construire Contrôler
un sous-agent = une boucle agentique avec un rôle étroit une règle métier critique se met en code, pas en consigne
chaque sous-agent ne reçoit que ses outils une porte entre deux étapes évite de lancer un sous-agent pour rien
le contexte est transmis explicitement, en JSON l’outil sensible vérifie lui-même ses conditions
le constat est construit à partir des résultats d’outils l’identité vérifiée est fixée par le coordinateur
l’agrégation produit un statut testable et un résumé la boucle gère les arrêts autres que end_turn et tool_use

Glossaire

Terme Définition
coordinateur le code ou l’agent qui découpe la tâche, lance les sous-agents et assemble les résultats
constat (finding) le résultat d’une étape transmis à la suivante, sous forme de champs testables
porte (gate) un contrôle en code entre deux étapes, qui bloque la suite si une condition n’est pas remplie
contrôle dans l’outil une vérification faite par la fonction de l’outil elle-même, quelle que soit la demande du modèle
journal des appels la liste des outils appelés, avec leurs entrées et leurs sorties
moindre privilège ne donner à chaque sous-agent que les outils de son rôle
is_error le champ d’un tool_result qui signale une vraie panne de l’outil
hook dans l’Agent SDK, une fonction qui intercepte un appel d’outil avant ou après son exécution

Quiz d’entraînement

Ces questions sont de moi, écrites dans le style de l’examen. Ce ne sont pas des questions officielles. Choisissez une réponse : la correction s’affiche aussitôt, et le score se calcule en bas. Connecté, votre meilleur score est gardé dans votre compte, et « Recommencer » efface seulement vos réponses à l’écran. Sans compte, rien n’est enregistré.

Un agent rembourse parfois sans avoir vérifié le client. Quelle correction est la plus fiable ?

Réponse C. Les consignes et les exemples réduisent la fréquence de l'erreur sans l'éliminer. Une porte ou un hook en code la rend impossible.

Un sous-agent renvoie {"result": "Le client semble exister", "status": "complete"}. Quel est le problème ?

Réponse A. L'enveloppe est structurée, pas le contenu. Il faut des champs comme verified: true, et status devrait refléter le résultat réel.

Quels outils le sous-agent de vérification doit-il recevoir ?

Réponse B. Le moindre privilège : un vérificateur qui peut rembourser peut sortir de son rôle.

get_customer ne trouve pas le client C999. Comment le signaler à Claude ?

Réponse D. L'outil a fonctionné et la réponse est « introuvable ». is_error est réservé aux vraies pannes, sinon Claude risque de réessayer en boucle.

La boucle ne teste que end_turn et tool_use. Que se passe-t-il avec max_tokens ?

Réponse B. Aucun des deux if ne s'exécute, l'historique ne change pas, et la même requête repart. Il faut traiter explicitement les autres arrêts.

Pourquoi client_verifie est-il absent du schéma de process_refund ?

Réponse C. Le coordinateur fixe le client vérifié avec partial. Le modèle ne choisit que la commande et le montant.

Le modèle écrit « remboursement effectué », mais process_refund a renvoyé ok: false. Quel statut afficher ?

Réponse A. Les faits sont dans les résultats d'outils. Le résumé du modèle sert à un humain, pas à décider du statut.

Dans l'API Messages, comment le coordinateur lance-t-il ici ses sous-agents ?

Réponse D. L'outil de lancement appartient à Claude Code et à l'Agent SDK. Avec l'API seule, chaque sous-agent est une boucle séparée, au contexte isolé.

Votre score

Quiz terminé. En dessous de 6, relisez les sections 5 et 6, puis recommencez.

Pour la suite

On a maintenant un programme complet : une boucle avec ses garde-fous, deux sous-agents limités à leur rôle, un constat structuré et des contrôles en code. L’épisode 5 continue le domaine 1 de l’examen.