Quatre agents existent désormais là où la Partie 4 en laissait un seul : un superviseur, un agent runbook et un agent de coûts rejoignent incident-triage, coordonnés non par des appels de fonctions Python au sein d'un même processus mais par la propre API InvokeAgentRuntime d'AgentCore Runtime, car les quatre se déploient toujours comme des ressources Runtime séparées et isolées, la même isolation pour laquelle la Partie 3 avait choisi AgentCore Runtime au départ. Une ressource AWS de plus les relie : une unique instance AgentCore Memory, partagée entre les quatre par actor ID, si bien qu'un diagnostic que le triage écrit à 3 heures du matin reste lisible pour l'agent runbook auquel il passe la main quelques secondes plus tard, et pour n'importe quel agent qui regardera un incident de la même forme le mois prochain.

La Partie 1 a choisi AgentCore plus Strands et esquissé quatre agents derrière un superviseur comme forme cible. La Partie 2 a construit la frontière de compte et la séparation ops-readonly/ops-mutate que chaque agent réutilise depuis. La Partie 3 a livré le premier agent, deux outils câblés directement dans son propre processus. La Partie 4 a déplacé ces outils, plus un second outil de lecture et l'unique outil mutant de la plateforme, derrière AgentCore Gateway : quatre Lambdas qu'un client MCP peut découvrir au lieu de quatre choses à câbler à la main par agent. Cette partie est celle où cet investissement commence à payer pour plus d'un appelant : les agents runbook et coûts se connectent tous deux à la même Gateway et héritent des quatre outils sans re-dériver la gestion d'identifiants cross-account de quoi que ce soit. Le code compagnon vit à github.com/flightlesstux/agents-on-call, dans agents/supervisor/, agents/runbook/, agents/cost_optimizer/, et les ajouts de cette partie à terraform/10-agent-runtime/ ; chaque extrait ci-dessous est tiré de ces fichiers, pas simplifié pour l'article.

Quand un seul agent cesse de suffire

L'agent de triage de la Partie 3 avait un system prompt, deux outils, une seule tâche. Cela a tenu sans difficulté jusqu'aux quatre outils de la Partie 4, parce que les quatre servaient toujours cette unique tâche : diagnostiquer, jamais remédier. La pression qui force réellement un deuxième agent n'est pas le nombre d'outils, c'est le nombre de tâches. Un agent runbook (transformer un diagnostic en proposition d'exécution SSM Automation) et un agent de coûts (lire Cost Explorer une fois par jour, sans sollicitation, et proposer du rightsizing) ne sont pas des variations de la tâche de triage, ce sont des tâches différentes, avec des calendriers différents et, si on les entasse dans un seul system prompt, des instructions contradictoires vivant dans la même fenêtre de contexte. « Tu ne prends jamais d'action mutante, tu n'as aucun outil qui le puisse » (la règle propre au triage, inchangée depuis la Partie 3) et « propose un appel ssm_execute quand un extrait de runbook correspondant le justifie » (la véritable tâche de l'agent runbook) ne sont pas des phrases qu'un seul prompt énonce proprement ensemble ; un agent à qui l'on demande de faire les deux laisse la discipline d'une tâche dériver vers la propension à agir de l'autre, ou perd la nuance de la seconde sous la prudence de la première. Répartir le travail entre processus, et pas seulement entre sections de prompt, est ce qui garde la discipline propre du triage lisible deux parties plus tard : son system prompt n'a pas changé d'une ligne depuis la Partie 3.

Le gonflement de contexte est la version plus discrète de la même pression. Un agent de triage qui porte aussi les particularités de Cost Explorer et le comportement de récupération du corpus de runbooks dépense des tokens de system prompt et du poids de schémas d'outils sur des tâches qu'il n'exécute pas lors d'une invocation donnée, la plupart du temps. Quatre agents, chacun ne portant que ce dont sa propre tâche a besoin, est une décision d'efficacité en tokens autant qu'une décision d'organisation.

Des agents comme outils, la forme qui convient à quatre Runtimes séparés

Strands Agents 1.0 a livré quatre nouvelles primitives multi-agents plus le support du protocole Agent-to-Agent dans sa version de production, et « agents as tools » a plus d'une forme réelle dans le SDK. Passer une instance Agent enfant directement dans la liste tools=[...] d'un parent, ou en envelopper une dans un décorateur qui l'expose avec un tool spec, fonctionnent bien tous les deux et exigent la même chose : le sous-agent vit dans le même processus Python que son appelant. Ce n'est pas la forme de cette plateforme. Triage, runbook et coûts se déploient chacun comme leur propre ressource aws_bedrockagentcore_agent_runtime, versionnée indépendamment, scopée IAM indépendamment, chacun dans l'isolation de session pour laquelle la Partie 3 a précisément choisi AgentCore Runtime. Fusionner trois Runtimes en un seul processus pour profiter du raccourci en processus déferait cette isolation pour une commodité syntaxique.

FormeComment ça marcheConvient à cette plateforme ?
tools=[agent_instance]Passer un objet Agent Strands directement dans la propre liste d'outils d'un parentNon : exige le sous-agent dans le même processus, fusionnant l'isolation de trois Runtimes séparés en un seul
.as_tool()Envelopper un appel d'agent en processus dans un décorateur qui expose un tool specNon, même raison : toujours un seul processus, un seul rayon d'impact s'il plante
@tool appelant InvokeAgentRuntimeUne simple fonction outil Strands qui appelle le Runtime déjà déployé d'un autre agent via le réseauOui : correspond à la forme quatre-Runtimes-séparés à laquelle le Terraform de la Partie 3 s'est déjà engagé

Le superviseur utilise donc la troisième forme : des fonctions décorées @tool qui appellent l'opération data-plane InvokeAgentRuntime de bedrock-agentcore contre l'ARN de runtime de chaque spécialiste. Le compromis est réel et mérite d'être nommé plutôt que caché : un appel en processus est un appel de fonction, ceci est un saut réseau avec sa propre latence et sa propre façon d'échouer à mi-chemin. Payé délibérément, pas par accident, pour l'isolation à laquelle la Partie 3 s'était déjà engagée :

def _invoke_specialist(agent_runtime_arn: str, actor_id: str, prompt: str) -> str:
    """Invoke one specialist's AgentCore Runtime and return its text result.
    ...
    """
    response = _agentcore.invoke_agent_runtime(
        agentRuntimeArn=agent_runtime_arn,
        runtimeSessionId=_session_id,
        contentType="application/json",
        payload=json.dumps({"prompt": prompt, "session_id": _session_id}).encode("utf-8"),
    )
    if response["statusCode"] != 200:
        raise RuntimeError(f"{actor_id} runtime invocation failed with status {response['statusCode']}")

    body = json.loads(response["response"].read())
    if "error" in body:
        raise RuntimeError(f"{actor_id} returned an error: {body['error']}")
    return body["result"]


@tool
def ask_triage_agent(alarm_context: str) -> str:
    """Ask the incident-triage specialist to diagnose a firing alarm.
    ...
    """
    result = _invoke_specialist(TRIAGE_AGENT_RUNTIME_ARN, "incident-triage", alarm_context)
    record_incident_event("supervisor", _session_id, {"delegated_to": "incident-triage", "result": result})
    return result

Deux détails méritent qu'on s'y arrête. D'abord, _invoke_specialist lève une exception sur un statut non-200 ou un corps d'erreur au lieu de retourner une chaîne qui se lit comme un constat normal : un superviseur incapable de distinguer « runbook n'a rien trouvé » de « l'invocation de runbook a échoué » synthétisera une fausse confiance dans ce qu'il raconte à un humain. Ensuite, _session_id est généré une fois par invocation du superviseur, pas une fois par appel de spécialiste, et transmis à chaque spécialiste sans modification : c'est ce qui permet à AgentCore Runtime de regrouper les traces des trois spécialistes sous un seul incident, et ce qui permet à la mémoire partagée ci-dessous de distinguer les événements d'un incident de ceux d'un autre.

L'agent runbook : Knowledge Base sur documents SSM, propose, n'exécute jamais

Toute la tâche de l'agent runbook consiste à transformer un diagnostic en remédiation candidate, sourcée depuis un vrai document, pas inventée. Il y parvient avec deux sources d'outils : un nouvel outil kb_retrieve sur une Bedrock Knowledge Base construite sur le corpus de runbooks, et chaque outil que la Gateway indexe déjà, découvert au moment de la connexion plutôt que déclaré à la main :

@tool
def kb_retrieve(query: str, max_results: int = 5) -> str:
    """Search the runbook Knowledge Base for playbook chunks relevant to query.
    ...
    """
    response = _bedrock_agent_runtime.retrieve(
        knowledgeBaseId=RUNBOOK_KNOWLEDGE_BASE_ID,
        retrievalQuery={"text": query},
        retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": max_results}},
    )
    ...
    return json.dumps({"query": query, "chunks": chunks})


_gateway = connect_gateway_tools()

agent = Agent(
    model=model,
    system_prompt=SYSTEM_PROMPT,
    tools=[kb_retrieve, *_gateway.list_tools_sync()],
    name="runbook",
    ...
)

connect_gateway_tools() signe chaque requête vers l'endpoint MCP de la Gateway avec les identifiants du propre rôle d'exécution du conteneur runtime, SigV4, en cohérence avec le choix de la Partie 4 de authorizer_type = "AWS_IAM" sur la ressource Gateway elle-même, et retourne les outils que ce rôle IAM peut atteindre : cloudwatch-read, logs-read, cost-read et ssm-execute, les quatre mêmes que la Partie 4 a construits, ni listés ni référencés nulle part dans le code propre de cet agent. Que le modèle appelle réellement ssm-execute est une décision de system prompt et d'IAM, pas une décision prise dans le code. Et ssm-execute n'a pas changé depuis la Partie 4 : il exige toujours diagnosis et blast_radius à chaque appel, ne fait toujours que démarrer le flux d'approbation Step Functions, jamais une action directe dans un compte spoke. Le system prompt de l'agent runbook en fait une règle dure plutôt qu'implicite : appeler ssm_execute est rapporté au superviseur comme « envoyé à un humain », jamais « fait », quelle que soit l'assurance du propre texte du modèle.

L'agent de coûts : un balayage planifié vers la même porte

L'agent de coûts est le seul spécialiste que rien ne réveille : EventBridge Scheduler l'invoque une fois par jour et par spoke, sans qu'aucune alarme ne le sollicite, le calendrier lui-même défini entièrement en Terraform plutôt que laissé à l'état d'affirmation dans une docstring :

resource "aws_scheduler_schedule" "cost_agent_daily" {
  name                = "${var.platform_name}-cost-agent-daily"
  description         = "Daily trigger for the cost agent's Cost Explorer sweep across configured spoke accounts."
  schedule_expression = "cron(0 6 * * ? *)"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = "arn:aws:scheduler:::aws-sdk:bedrockagentcore:invokeAgentRuntime"
    role_arn = aws_iam_role.cost_scheduler.arn

    ...
  }
}

Il lit à travers la même connexion Gateway que runbook (cost-read, plus cloudwatch-read et logs-read pour corroborer une anomalie de dépense contre la charge réelle avant de la signaler), et quand une action de rightsizing semble justifiée, il la propose via le chemin ssm-execute identique à celui qu'utilise runbook. Un seul pipeline d'approbation, deux proposeurs : un humain qui passe en revue Slack ne devrait pas avoir à savoir ni à se soucier de savoir si une proposition en attente vient d'un diagnostic d'incident ou d'un balayage nocturne.

La seule chose qui mérite d'être conçue délibérément est de ne pas rejuger un refus. Un balayage quotidien sans mémoire de la veille signalera la même instance « idle » chaque matin jusqu'à ce qu'un humain l'approuve ou se lasse de la refuser, et aucune des deux issues n'est bonne :

prior_episodes = retrieve_incident_context("cost", prompt)
if prior_episodes:
    prompt = (
        f"{prompt}\n\nNote: {len(prior_episodes)} related past cost proposal(s) "
        "exist in memory; check whether any were denied before re-proposing."
    )

AgentCore Memory : un incident partagé, quatre agents qui écrivent

AgentCore Memory facture les événements court terme à 0,25 $ par 1 000 appels CreateEvent et la récupération long terme à 0,50 $ par 1 000 appels RetrieveMemoryRecords, assez peu cher pour que quatre agents écrivant chacun un événement par invocation coûtent une erreur d'arrondi à côté du prix en tokens d'un seul appel de modèle. La stratégie qui fait le travail ici est EPISODIC, un des types intégrés d'AWS : elle capture chaque session comme un épisode structuré (contexte, raisonnement, actions, résultats) et exécute sa propre étape de réflexion à travers les épisodes pour en extraire des motifs plus larges, sans que cette plateforme ait besoin d'orchestrer cette extraction par prompt engineering elle-même. Le support Terraform du type EPISODIC sur aws_bedrockagentcore_memory_strategy a atterri le 29 avril 2026, presque deux mois avant la date propre de cette partie :

resource "aws_bedrockagentcore_memory" "incident" {
  name                  = "${replace(var.platform_name, "-", "_")}_incident_memory"
  description           = "Shared short-term event stream and long-term episodic memory for one incident's supervisor, triage, runbook, and cost agents."
  event_expiry_duration = var.memory_event_expiry_days

  # memory_execution_role_arn deliberately omitted: per the fact table, it
  # is required only when a memory uses a CUSTOM strategy with a
  # model-processing override block (SEMANTIC_OVERRIDE,
  # USER_PREFERENCE_OVERRIDE, and so on). EPISODIC below is a built-in
  # type, not CUSTOM, so AWS runs its own reflection step without this
  # platform needing to grant or manage a role for it.
  tags = merge(var.tags, { Component = "memory" })
}

...

resource "aws_bedrockagentcore_memory_strategy" "episodic" {
  name       = "incident-episodes"
  memory_id  = aws_bedrockagentcore_memory.incident.id
  type       = "EPISODIC"
  namespaces = ["/incidents/{actorId}"]
}

Chaque agent écrit dans cette unique mémoire via les deux mêmes fonctions, ce qui empêche quatre Runtimes déployés indépendamment de diverger sur la façon de lire et d'écrire l'état partagé :

def record_incident_event(actor_id: str, session_id: str, event: dict) -> str | None:
    """Write one short-term event to the shared incident memory.
    ...
    """
    if not MEMORY_ID:
        return None

    response = _agentcore.create_event(
        memoryId=MEMORY_ID,
        actorId=actor_id,
        sessionId=session_id,
        eventTimestamp=time.time(),
        payload=[
            {
                "conversational": {
                    "content": {"text": json.dumps(event, default=str)},
                    "role": "ASSISTANT",
                }
            }
        ],
    )
    return response["event"]["eventId"]

Deux choses qu'elle ne fait délibérément pas. Elle retourne None au lieu de lever une exception quand l'ID de mémoire n'est pas défini, si bien qu'un agent testé en local sans ressource mémoire provisionnée se dégrade en « pas de mémoire partagée » plutôt que d'échouer un diagnostic sur une variable d'environnement manquante. Et actor_id est une chaîne qu'un agent passe, pas une valeur que cette fonction valide contre quoi que ce soit, à dessein : ajouter un cinquième agent plus tard n'exige aucun changement de schéma ici, seulement un nouvel acteur écrivant dans la même forme de namespace.

Honnêtement : quand le multi-agent est surdimensionné

Quatre Runtimes AgentCore plus une ressource mémoire plus du réseau inter-runtimes, c'est de la vraie infrastructure pour ce qu'une équipe ops plus petite pourrait faire tourner comme un seul agent avec sept outils et un system prompt plus long. AgentCore Runtime facture 0,0895 $ par vCPU-heure et 0,00945 $ par GB-heure avec le temps d'inactivité gratuit, donc quatre petits runtimes ne coûtent pas cher en termes absolus, mais ce sont quatre choses à déployer, quatre rôles IAM à auditer, et la latence et la surface d'échec d'un saut InvokeAgentRuntime qu'un processus unique n'a jamais. Le déclencheur honnête pour scinder n'est pas « cette plateforme a grandi », c'est celui par lequel cette partie s'est ouverte : des tâches distinctes dont les instructions se contredisent dans un seul system prompt, ou des calendriers distincts (un agent déclenché par alarme et un balayage quotidien non sollicité) qui ne partagent pas de boucle unique naturelle. Une plateforme ops avec une seule tâche, le triage et rien d'autre, ne subit aucune de ces pressions et devrait rester un seul agent avec de bons outils : pas d'appel inter-runtimes à raisonner, pas de ressource mémoire à empêcher de rancir, pas de module Terraform en for_each coordonnant trois rôles qui pourraient tout aussi bien n'en être qu'un. Scinder tôt, avant qu'une deuxième vraie tâche existe, achète une isolation dont personne n'avait encore besoin au prix d'un saut réseau dont personne ne voulait encore non plus.

Modes de défaillance à surveiller

Quatre méritent d'être nommés avant que ceci ne tourne contre un vrai incident. Le triage n'écrit pas encore dans la mémoire partagée : son code de la Partie 3 est antérieur à la ressource mémoire de cette partie, et rien dans cette partie ne le met à niveau, ce qui signifie que les épisodes de la stratégie de réflexion manquent le seul agent qui s'exécute en premier sur presque chaque incident, une vraie lacune, pas un oubli à passer sous silence. Un appel inter-runtimes qui échoue à mi-chemin est une défaillance différente d'un appel en processus : _invoke_specialist qui lève une exception en plein incident laisse ce que le superviseur avait déjà enregistré en mémoire tel quel, une chronologie partielle, utile pour un humain qui reprend l'incident à la main mais facile à lire comme complète si personne ne vérifie. L'étape de réflexion d'EPISODIC s'exécute de façon asynchrone sur des sessions closes, donc retrieve_incident_context retournant une liste vide signifie soit « réellement nouveau » soit « la réflexion n'a pas encore tourné », et ni ce code ni le prompt de l'agent ne savent aujourd'hui les distinguer. Et les politiques IAM des nouveaux agents ont une vraie dépendance d'ordre : la politique du rôle du superviseur référence les propres ARN des runtimes runbook et coûts, donc un apply Terraform interrompu entre le provisionnement de ces runtimes et celui du rôle du superviseur laisse le plan suivant montrer la politique du superviseur comme pas-encore-appliquée, pas une mauvaise configuration durable, mais une raison de laisser un apply se terminer plutôt que de faire confiance à un apply partiel.

À lire ensuite

Les répertoires complets agents/supervisor/, agents/runbook/, agents/cost_optimizer/, et le Terraform de mémoire et de runtime derrière eux 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 des plateformes comme celle-ci à grande échelle, les notes de terrain sont sur ercan.cloud, et le hub se trouve sur ercanermis.com.