Le premier agent fonctionnel de cette série tient en environ 260 lignes de Python : un Agent Strands, deux outils en lecture seule qui endossent un rôle IAM cross-account avant d'appeler boto3, et un system prompt dont tout le travail consiste à empêcher le modèle de sembler sûr de lui sur quelque chose qu'il n'a pas réellement vérifié. Pas encore d'AgentCore Gateway, pas de superviseur, pas de handoff multi-agent : cela viendra dans les parties suivantes. Cette partie porte sur le fait de faire fonctionner correctement un seul agent pour une seule tâche, déployé sur AgentCore Runtime, avant d'ajouter quoi que ce soit qui compliquerait son débogage.

La Partie 1 a planté le décor (une entreprise SaaS de 50 ingénieurs, 40 pages par semaine, la lecture seule imposée par IAM comme contrainte non négociable) et a choisi AgentCore plus Strands plutôt que les Bedrock Agents classiques ou une boucle construite maison. La Partie 2 a construit la frontière de compte dans laquelle vivent ces agents : les rôles spoke ops-readonly et ops-mutate, et un profil d'inférence applicatif par agent enveloppant le routage cross-region de Bedrock. Cette partie utilise directement les deux. Rien ici n'est provisionné depuis zéro ; elle consomme ce que la Partie 2 a déjà construit. Le code compagnon vit sur github.com/flightlesstux/agents-on-call, dans agents/triage/agent.py et terraform/10-agent-runtime/, et chaque extrait ci-dessous est tiré de ces fichiers, pas simplifié pour l'article.

Anatomie d'un agent Strands

Strands Agents est un SDK Python piloté par le modèle, open source par AWS en mai 2025 sous licence Apache 2.0, et utilisé en interne pour Amazon Q Developer, AWS Glue et VPC Reachability Analyzer avant même de sortir des murs d'Amazon. L'argument de vente n'est pas un nouveau langage d'orchestration : c'est une fine boucle autour d'un modèle qui sait déjà appeler des outils, reliée à Bedrock, à Anthropic en direct, à Ollama, ou à un fournisseur proxifié par LiteLLM, sans changer la façon dont l'agent est écrit. La forme entière d'un agent tient en cinq étapes.

1. La configuration vient de l'environnement, pas de littéraux dans le fichier. Tout ce que le runtime a besoin de savoir est défini comme variable d'environnement par le module Terraform ci-dessous, si bien que rien de spécifique à un compte ou de secret n'est codé en dur dans le code source :

AWS_REGION = os.environ.get("AWS_REGION", "eu-west-1")
INFERENCE_PROFILE_ARN = os.environ["AGENT_INFERENCE_PROFILE_ARN"]
OPS_READONLY_ROLE_NAME = os.environ.get("OPS_READONLY_ROLE_NAME", "ops-readonly")
SPOKE_EXTERNAL_ID = os.environ["SPOKE_EXTERNAL_ID"]

2. Les outils sont de simples fonctions Python avec un décorateur. Strands lit les type hints et la docstring d'une fonction pour construire la spécification d'outil que le modèle voit réellement, donc la docstring n'est pas un commentaire pour les humains, c'est le contrat d'interface :

@tool
def cloudwatch_read(
    spoke_account_id: str,
    namespace: str,
    metric_name: str,
    dimensions: dict[str, str],
    lookback_minutes: int = 60,
    stat: str = "Average",
    period_seconds: int = 60,
) -> str:
    """Read a CloudWatch metric's recent datapoints from a spoke account.
    ...
    """

3. Le modèle est câblé via le profil d'inférence de la Partie 2, pas un ID de modèle brut. La température reste basse volontairement : cet agent propose un diagnostic à partir de preuves, il n'a pas besoin de variété créative dans sa formulation.

model = BedrockModel(
    model_id=INFERENCE_PROFILE_ARN,
    region_name=AWS_REGION,
    temperature=0.1,
    max_tokens=2048,
)

4. L'agent lui-même tient en un seul appel de constructeur : un modèle, un system prompt, et une liste d'outils. Il n'y a aucun graphe d'orchestration à écrire, parce que Strands déduit la boucle d'appel d'outils à partir de la propre sortie de tool-use du modèle :

agent = Agent(
    model=model,
    system_prompt=SYSTEM_PROMPT,
    tools=[cloudwatch_read, logs_read],
    name="incident-triage",
    description=(
        "First-pass responder for CloudWatch alarms: reads metrics and logs, proposes a "
        "root cause candidate, defers all remediation to a human or the runbook agent."
    ),
)

5. Un entrypoint AgentCore Runtime l'enveloppe. BedrockAgentCoreApp vient du package séparé bedrock-agentcore, pas de Strands lui-même : Strands est le framework d'agent, AgentCore est la couche d'hébergement, et le décorateur @app.entrypoint est la jointure entre les deux.

app = BedrockAgentCoreApp()

@app.entrypoint
def invoke(payload: dict) -> dict:
    """AgentCore Runtime entrypoint.
    ...
    """
    prompt = payload.get("prompt")
    if not prompt:
        return {"error": "payload.prompt is required"}

    result = agent(prompt)
    return {"result": str(result)}

if __name__ == "__main__":
    app.run()

Appeler agent(prompt) est toute la boucle d'agent vue de l'extérieur : Strands envoie le prompt et les spécifications d'outils à Bedrock, récupère soit une réponse finale soit une requête de tool-use, exécute l'outil demandé, renvoie le résultat, et répète jusqu'à ce que le modèle arrête de demander des outils. Rien de cette boucle n'est du code que ce projet possède ; c'est du code que ce projet aurait dû écrire à la main sous l'option de construction maison que la Partie 1 a rejetée.

Conception du system prompt : les preuves avant les conclusions

Un agent de triage qui semble sûr de lui est plus dangereux qu'un agent qui dit « je ne sais pas encore », parce qu'un humain qui lit sa sortie à trois heures du matin agit autant sur le ton que sur le contenu. Le system prompt existe presque entièrement pour empêcher ce mode de défaillance, pas pour rendre l'agent plus intelligent :

1. Les preuves avant les conclusions. Appelez cloudwatch_read et logs_read avant d'énoncer un
   quelconque candidat de cause racine. Une hypothèse formée avant d'avoir interrogé au moins une métrique
   et un groupe de logs est une supposition, pas un diagnostic, et vous ne devez pas la présenter comme telle.
2. Citez le nom de la métrique et le namespace pour chaque affirmation : dites « AWS/ApplicationELB
   TargetResponseTime est passé de 120ms à 4.8s au cours des 15 dernières minutes », pas « la latence a
   augmenté ». Un lecteur sans accès à vos appels d'outils doit pouvoir vérifier chaque chiffre que vous
   énoncez en relançant la même requête.
3. Énoncez l'incertitude explicitement. ...
4. Aucune spéculation sur des causes que vos outils ne peuvent pas voir. ...
5. Les résultats vides sont des constats. Une métrique sans point de données dans la fenêtre de requête, ou une
   requête de log qui retourne zéro ligne, est une preuve (la défaillance est silencieuse, ou en amont de ce
   groupe de logs), pas une raison de relancer avec une fenêtre plus large jusqu'à ce que quelque chose apparaisse.
6. Terminez par un résumé structuré : l'alerte, le ou les candidats de cause racine avec leur confiance,
   les métriques et requêtes de log exactes que vous avez exécutées, et ce que vous vérifieriez ensuite si vous
   aviez un appel d'outil de plus.

La règle deux fait le plus gros du travail. « Citez le nom de la métrique » ressemble à une préférence stylistique, mais c'est ce qui transforme la sortie de l'agent d'une affirmation que l'humain doit croire sur parole en une affirmation que l'humain peut vérifier en quinze secondes en collant la même requête CloudWatch. La règle cinq existe parce que l'échec naturel d'un agent disposant d'un budget de retries est de continuer à élargir la fenêtre temporelle ou à modifier la requête jusqu'à trouver quelque chose, moment auquel ce « quelque chose » est rapporté comme la cause même s'il n'a aucun rapport. Un résultat vide est une donnée. Le traiter comme une impasse à rapporter, plutôt que comme une invitation à continuer de pêcher, est ce qui empêche l'agent de fabriquer un diagnostic à partir de bruit.

Deux outils, du Python pur, pas encore de Gateway

L'architecture cible de la Partie 1 place chaque outil derrière AgentCore Gateway : compatible MCP, scopé IAM de façon centralisée, une seule liste blanche pour quatre outils sur toute la plateforme. Ce sera la Partie 4. Ici, avec exactement un agent et deux outils, Gateway ne serait qu'une indirection sans encore rien vers quoi router. Les deux outils sont de simples fonctions Python décorées avec @tool que l'agent appelle directement, et toutes deux font la même chose avant de toucher AWS : endosser ops-readonly dans le compte spoke cible.

def _assume_ops_readonly(spoke_account_id: str) -> boto3.Session:
    sts = boto3.client("sts", region_name=AWS_REGION, config=_boto_config)
    role_arn = f"arn:aws:iam::{spoke_account_id}:role/{OPS_READONLY_ROLE_NAME}"
    creds = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName="triage-agent-read",
        ExternalId=SPOKE_EXTERNAL_ID,
        DurationSeconds=900,
    )["Credentials"]
    return boto3.Session(
        aws_access_key_id=creds["AccessKeyId"],
        aws_secret_access_key=creds["SecretAccessKey"],
        aws_session_token=creds["SessionToken"],
        region_name=AWS_REGION,
    )

Quinze minutes, pas le plafond d'une heure qu'autorise la trust policy d'ops-readonly : un appel d'outil qui a encore besoin d'identifiants après quinze minutes est bloqué sur autre chose, et un identifiant à durée de vie plus courte réduit le rayon d'impact s'il venait à fuir hors du processus. cloudwatch_read enveloppe get_metric_statistics et retourne les points de données en JSON, du plus ancien au plus récent. logs_read démarre une requête CloudWatch Logs Insights et sonde jusqu'à complétion, plafonné à dix-huit secondes sur vingt tentatives, et lève une exception plutôt que de retourner un résultat vide sur une requête bloquée : une requête de log qui expire silencieusement et rapporte zéro ligne correspondante se lit exactement comme « aucune erreur trouvée », ce qui est le seul mode de défaillance que cet outil ne peut pas se permettre d'avoir. Les deux outils acceptent un argument spoke_account_id, ce qui signifie que le modèle décide de quel compte interroger en fonction de ce que lui indique l'événement d'alerte, et non d'une valeur figée dans le déploiement.

Ce qu'AgentCore Runtime apporte par rapport à Lambda ou ECS

Rien ici n'exige à proprement parler AgentCore Runtime. Un agent Strands est du Python ordinaire ; il pourrait tourner dans une fonction Lambda ou une tâche ECS avec un client boto3 et une boucle. AgentCore Runtime, en disponibilité générale depuis le 13 octobre 2025, apporte trois choses qui seraient autrement de l'infrastructure sur mesure :

  • Une fenêtre d'exécution de huit heures par session. Le plafond strict de quinze minutes de Lambda force soit un agent à très courte durée de vie, soit un mécanisme de continuation construit à la main entre invocations pour tout ce qui dure plus longtemps, ce qu'une boucle de diagnostic multi-outils avec retries peut faire. Le modèle de session d'AgentCore Runtime n'a pas besoin de ce contournement.
  • Une isolation complète des sessions entre invocations, sans avoir à construire à la main un bac à sable par session à partir de contextes d'exécution Lambda ou de tâches ECS séparés.
  • Une facturation à la seconde sans frais pour le CPU inactif. 0,0895 $ par vCPU-heure et 0,00945 $ par Go-heure, facturé à la seconde, avec un plancher de mémoire de 128 Mo, et spécifiquement aucun frais pour le temps qu'un agent passe bloqué à attendre une réponse du LLM ou un appel d'outil, ce qui représente typiquement 30 à 70 % du temps d'horloge murale d'une session. Une tâche ECS ou une Lambda provisionnée facturerait ce temps d'inactivité, que le CPU fasse quelque chose ou non.

Le compromis est une contrainte réelle, pas une note de bas de page : AgentCore Runtime ne supporte que le jeu d'instructions ARM64, donc toute dépendance avec des extensions compilées doit résoudre vers une wheel ARM64 ou manylinux, sinon le déploiement échoue à la validation d'en-tête ELF au moment de l'upload. Les deux dépendances de cet agent, strands-agents et bedrock-agentcore, n'embarquent aujourd'hui aucune extension compilée, donc cela ne mord pas ici. Cela mordra le premier agent de cette série qui aura besoin de quelque chose comme un driver de base de données natif, et le correctif à ce moment-là consiste à construire l'artefact de déploiement avec pip install --platform manylinux2014_aarch64 --only-binary=:all: plutôt qu'un simple pip install.

Le Terraform du runtime

aws_bedrockagentcore_agent_runtime supporte deux types d'artefacts mutuellement exclusifs sous agent_runtime_artifact : code_configuration, un zip S3 avec une version de runtime et un point d'entrée, ou container_configuration, une URI d'image ECR. Ce module utilise code_configuration. Le CLI d'AgentCore lui-même fait par défaut passer les nouveaux agents par le même chemin de déploiement de code direct (appelé en interne CodeZip) plutôt que par une construction de conteneur, et avec exactement deux dépendances et aucun paquet système à installer, un Dockerfile serait une cérémonie dont cet agent n'a pas encore besoin. Un futur agent dont les dépendances ont besoin d'être compilées, ou dont le runtime n'est pas Python, est le déclencheur pour basculer vers container_configuration, pas une préférence par défaut pour un type d'artefact plutôt qu'un autre.

resource "aws_bedrockagentcore_agent_runtime" "triage" {
  agent_runtime_name = "${replace(var.platform_name, "-", "_")}_triage"
  description        = "Incident-triage agent: reads CloudWatch metrics and Logs Insights across spokes, diagnoses, never mutates."
  role_arn           = aws_iam_role.triage_runtime.arn

  agent_runtime_artifact {
    code_configuration {
      entry_point = ["agent.py"]
      runtime     = "PYTHON_3_12"

      code {
        s3 {
          bucket     = aws_s3_bucket.agent_artifacts.id
          prefix     = aws_s3_object.triage_agent_code.key
          version_id = aws_s3_object.triage_agent_code.version_id
        }
      }
    }
  }

  environment_variables = {
    AWS_REGION                  = var.aws_region
    AGENT_INFERENCE_PROFILE_ARN = var.agent_inference_profile_arn
    OPS_READONLY_ROLE_NAME      = var.ops_readonly_role_name
    SPOKE_EXTERNAL_ID           = var.spoke_external_id
  }

  network_configuration {
    network_mode = "PUBLIC"
  }

  protocol_configuration {
    server_protocol = "HTTP"
  }

  tags = merge(var.tags, {
    Agent = "triage"
  })
}

network_mode = "PUBLIC" plutôt qu'un rattachement VPC est délibéré : les seuls appels sortants de cet agent sont l'invocation de modèle Bedrock et le sts:AssumeRole cross-account, deux endpoints de service AWS publics atteints via HTTPS authentifié par IAM. Il n'y a aucune ressource réseau privée, une base de données ou un load balancer interne, à laquelle cet agent parle directement, donc un rattachement VPC ajouterait un coût de NAT et d'endpoint pour rien. La politique de permissions du rôle d'exécution est scopée dans trois directions qui reflètent les trois choses que cet agent est réellement autorisé à toucher : bedrock:InvokeModel sur l'ARN du profil d'inférence et sur les ARN des modèles de fondation sous-jacents vers lesquels il peut router (les deux, parce que l'inférence cross-region autorise au niveau du profil et au niveau du modèle de destination, et l'omission de la seconde autorisation ne fait échouer que les invocations qui se trouvent routées vers une région qui ne l'a pas, exactement l'échec d'allure intermittente que la Partie 2 a signalé pour l'accès aux modèles lui-même) ; sts:AssumeRole scopé à l'ARN exact d'ops-readonly dans chaque compte spoke que cet agent est autorisé à interroger, construit à partir d'une liste, pas d'un wildcard ; et s3:GetObject sur son propre préfixe d'artefact de code, rien de plus large.

Un premier diagnostic, de l'alerte au résumé

Une alerte CloudWatch sur AWS/ApplicationELB TargetResponseTime se déclenche pour le service checkout dans un compte spoke. L'agent superviseur n'existe pas encore (Partie 5), donc pour cette partie, un humain colle directement le contexte de l'alerte dans l'agent. Ce qui revient, condensé :

> cloudwatch_read(spoke_account_id="222222222222",
    namespace="AWS/ApplicationELB", metric_name="TargetResponseTime",
    dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
    lookback_minutes=30)
< 30 points de données, moyenne montant de 0,14 s à 4,6 s sur les 12 dernières minutes

> cloudwatch_read(spoke_account_id="222222222222",
    namespace="AWS/ApplicationELB", metric_name="HTTPCode_Target_5XX_Count",
    dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
    lookback_minutes=30)
< nombre de 5XX plat proche de zéro pendant 18 minutes, puis en hausse avec TargetResponseTime

> logs_read(spoke_account_id="222222222222",
    log_group_name="/aws/ecs/prod-checkout",
    query_string='fields @timestamp, @message | filter @message like /timeout|pool/ | sort @timestamp desc',
    lookback_minutes=15)
< 41 lignes, message dominant : "HikariPool-1 - Connection is not available,
  request timed out after 30000ms"

Le résumé de l'agent : AWS/ApplicationELB TargetResponseTime sur app/prod-checkout est passé de 140 ms à 4,6 s en 12 minutes, avec HTTPCode_Target_5XX_Count qui commence à grimper environ six minutes après le début de la hausse de latence. CloudWatch Logs Insights sur /aws/ecs/prod-checkout dans la même fenêtre retourne 41 lignes correspondant à un timeout de pool de connexions, cause racine la plus probable : le pool de connexions à la base de données du service est épuisé, et les requêtes s'accumulent en file d'attente jusqu'à atteindre le timeout de pool de 30 secondes, ce qui se manifeste par une hausse de TargetResponseTime avant de devenir un 5XX pur et simple. Confiance : modérée, pas élevée, parce que cet agent n'a aucun outil capable de lire le nombre de connexions réel de la base de données ou le maximum configuré du pool, donc il ne peut pas confirmer que le pool est réellement plein plutôt que, par exemple, une dépendance en aval qui garde des connexions ouvertes plus longtemps que d'habitude. Prochaine vérification recommandée : les métriques de connexion base de données de l'agent runbook, une fois que cet agent existera, ou un humain vérifiant directement le nombre actuel de connexions actives du pool.

Ce dernier paragraphe est le résultat des règles du system prompt fonctionnant comme prévu, pas une prudence ajoutée pour l'article. L'agent dispose de preuves réelles pour une hypothèse réelle, cite les métriques et la requête exactes qui la sous-tendent, et s'arrête précisément à la limite de ce que ses deux outils peuvent réellement voir, en nommant l'angle mort plutôt qu'en devinant au travers.

Modes de défaillance à surveiller

Trois choses à connaître avant que cet agent ne tourne contre une vraie alerte. D'abord, un échec d'AssumeRole contre ops-readonly, mauvais ID de compte, external ID expiré, spoke pas encore onboardé, doit ressortir comme « je n'ai pas pu lire ce compte » dans le résumé final, pas comme un appel d'outil silencieusement ignoré que le modèle contourne en raisonnant à partir de la seule source de données qui a réussi ; un diagnostic partiel présenté avec la même confiance qu'un diagnostic complet est pire qu'aucun diagnostic. Ensuite, le timeout de dix-huit secondes de Logs Insights est un plafond réel : une requête contre un groupe de logs à fort volume avec un filtre large peut légitimement prendre plus longtemps, et le correctif est une requête plus resserrée (fenêtre temporelle plus étroite, filtre plus spécifique) de la part du modèle, pas un timeout plus long qui transformerait un appel d'outil bloqué en une invocation d'agent bloquée. Enfin, une température basse réduit la variance de formulation mais ne garantit pas que le modèle appelle les deux outils avant de répondre ; la règle un du system prompt est le véritable mécanisme d'application, et cela vaut la peine de tester contre des alertes délibérément conçues pour tenter un raccourci, une alerte dont le seul nom implique fortement une cause, pour confirmer que l'agent interroge toujours avant de conclure.

À lire ensuite

L'agent.py complet et le module terraform/10-agent-runtime/ complet, y compris les documents de politique IAM retirés des extraits ci-dessus, vivent dans le dépôt compagnon à github.com/flightlesstux/agents-on-call. Pour le volet base de données et infrastructure de faire tourner ce genre de plateforme à grande échelle, les notes de terrain sont sur ercan.cloud, et le hub se trouve sur ercanermis.com.