Il primo agente funzionante di questa serie è circa 260 righe di Python: un Agent Strands, due tool in sola lettura che assumono un ruolo IAM cross-account prima di chiamare boto3, e un system prompt il cui unico compito è impedire al modello di sembrare sicuro di qualcosa che non ha effettivamente verificato. Nessun AgentCore Gateway ancora, nessun supervisore, nessun passaggio di consegne multi-agente: arriveranno nelle parti successive. Questa parte riguarda il far fare a un solo agente un solo lavoro correttamente, distribuito su AgentCore Runtime, prima di aggiungere qualsiasi cosa che ne renda più difficile il debug.

La Parte 1 ha impostato lo scenario (una SaaS con 50 ingegneri, 40 pagine a settimana, sola lettura imposta da IAM come vincolo non negoziabile) e ha scelto AgentCore più Strands rispetto ai Bedrock Agents classici o a un loop costruito da zero. La Parte 2 ha costruito il confine di account in cui questi agenti vivono: i ruoli spoke ops-readonly e ops-mutate, e un application inference profile per agente che avvolge il routing cross-region di Bedrock. Questa parte usa entrambi direttamente. Niente qui viene provisionato da zero; consuma ciò che la Parte 2 ha già costruito. Il codice di accompagnamento vive su github.com/flightlesstux/agents-on-call, in agents/triage/agent.py e terraform/10-agent-runtime/, e ogni snippet qui sotto è estratto da quei file, non semplificato per il post.

Anatomia di un agente Strands

Strands Agents è un SDK Python model-driven, reso open source da AWS a maggio 2025 sotto licenza Apache 2.0 e usato internamente per Amazon Q Developer, AWS Glue e VPC Reachability Analyzer prima ancora di uscire dai laboratori interni. Il punto di forza non è un nuovo linguaggio di orchestrazione: è un loop sottile attorno a un modello che sa già chiamare tool, collegato a Bedrock, Anthropic diretto, Ollama, o a un provider proxato tramite LiteLLM senza cambiare il modo in cui l'agente viene scritto. La forma completa di un agente sta in cinque passi.

1. La configurazione arriva dall'ambiente, non da valori letterali nel file. Tutto ciò che il runtime deve sapere è impostato come variabile d'ambiente dal modulo Terraform qui sotto, quindi nulla di specifico per l'account o segreto è hardcoded nel sorgente:

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. I tool sono semplici funzioni Python con un decoratore. Strands legge i type hint e la docstring di una funzione per costruire la tool spec che il modello vede realmente, quindi la docstring non è un commento per gli umani, è il contratto dell'interfaccia:

@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. Il modello è collegato tramite l'inference profile della Parte 2, non un ID di modello grezzo. La temperature resta bassa di proposito: questo agente propone una diagnosi a partire da evidenze, non ha bisogno di varietà creativa nella formulazione.

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

4. L'agente stesso è un'unica chiamata al costruttore: un modello, un system prompt e una lista di tool. Non c'è nessun grafo di orchestrazione da scrivere, perché Strands inferisce il loop di chiamata dei tool direttamente dall'output di tool-use del modello:

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 di AgentCore Runtime lo avvolge. BedrockAgentCoreApp viene dal pacchetto separato bedrock-agentcore, non da Strands stesso: Strands è il framework agente, AgentCore è il layer di hosting, e il decoratore @app.entrypoint è la giunzione tra i due.

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()

Chiamare agent(prompt) è l'intero loop dell'agente visto dall'esterno: Strands invia il prompt e le tool spec a Bedrock, riceve indietro o una risposta finale o una richiesta di tool-use, esegue il tool richiesto, restituisce il risultato, e ripete finché il modello smette di chiedere tool. Nessuna parte di quel loop è codice di proprietà di questo progetto; è codice che questo progetto avrebbe dovuto scrivere a mano sotto l'opzione self-built che la Parte 1 ha scartato.

Design del system prompt: evidenze prima delle conclusioni

Un agente di triage che suona sicuro di sé è più pericoloso di uno che dice "non lo so ancora", perché un umano che legge il suo output alle tre del mattino agisce sul tono tanto quanto sul contenuto. Il system prompt esiste quasi interamente per prevenire questa modalità di fallimento, non per rendere l'agente più intelligente:

1. Evidence before conclusions. Call cloudwatch_read and logs_read before stating any
   root cause candidate. A hypothesis formed before you have queried at least one metric
   and one log group is a guess, not a diagnosis, and you must not present it as one.
2. Cite the metric name and namespace for every claim: say "AWS/ApplicationELB
   TargetResponseTime rose from 120ms to 4.8s over the last 15 minutes", not "latency went
   up". A reader with no access to your tool calls should be able to verify every number you
   state by re-running the same query.
3. State uncertainty explicitly. ...
4. No speculation about causes your tools cannot see. ...
5. Empty results are findings. A metric with no datapoints in the query window, or a log
   query that returns zero rows, is evidence (the failure is silent, or upstream of this log
   group), not a reason to retry with a wider window until something shows up.
6. Close with a structured summary: the alarm, the root cause candidate(s) with confidence,
   the exact metrics and log queries you ran, and what you would check next if you had one
   more tool call.

La regola due è quella che fa il lavoro più pesante. "Cita il nome della metrica" suona come una preferenza stilistica, ma è ciò che trasforma l'output dell'agente da un'affermazione che un umano deve credere sulla fiducia a un'affermazione che un umano può verificare in quindici secondi incollando la stessa query CloudWatch. La regola cinque esiste perché il fallimento naturale di un agente con un budget di retry è continuare ad allargare la finestra temporale o cambiare la query finché non trova qualcosa, a quel punto "qualcosa" viene riportato come causa anche quando non c'entra nulla. Un risultato vuoto è un dato. Trattarlo come un vicolo cieco da riportare, invece che uno spunto per continuare a pescare, è ciò che impedisce all'agente di fabbricare una diagnosi dal rumore.

Due tool, Python semplice, nessun Gateway ancora

L'architettura target della Parte 1 mette ogni tool dietro AgentCore Gateway: compatibile MCP, con scoping IAM centralizzato, un'unica allowlist per quattro tool sull'intera piattaforma. Quella è la Parte 4. Qui, con esattamente un agente e due tool, Gateway sarebbe indirection senza ancora nulla tra cui instradare. Entrambi i tool sono semplici funzioni Python decorate con @tool che l'agente chiama direttamente, ed entrambi fanno la stessa cosa prima di toccare AWS: assumere ops-readonly nell'account spoke target.

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,
    )

Quindici minuti, non il tetto di un'ora che la trust policy di ops-readonly consente: una chiamata a un tool che ha ancora bisogno di credenziali dopo quindici minuti è bloccata su qualcos'altro, e una credenziale a vita più breve riduce il raggio d'impatto se mai trapelasse fuori dal processo. cloudwatch_read avvolge get_metric_statistics e restituisce i datapoint come JSON, dal più vecchio al più recente. logs_read avvia una query CloudWatch Logs Insights e fa polling fino al completamento, con un tetto di diciotto secondi su venti tentativi, e solleva un'eccezione invece di restituire un risultato vuoto su una query bloccata: una query di log che va in timeout silenziosamente e riporta zero righe corrispondenti si legge esattamente come "nessun errore trovato", che è l'unica modalità di fallimento che questo tool non può permettersi di avere. Entrambi i tool accettano un argomento spoke_account_id, il che significa che il modello decide quale account interrogare in base a cosa gli dice l'evento di allarme, non un valore incorporato nel deployment.

Cosa compra AgentCore Runtime rispetto a Lambda o ECS

Nulla qui richiede rigorosamente AgentCore Runtime. Un agente Strands è normale Python; potrebbe girare in una funzione Lambda o in un task ECS con un client boto3 e un loop. AgentCore Runtime, in general availability dal 13 ottobre 2025, compra tre cose che altrimenti sarebbero infrastruttura custom:

  • Una finestra di esecuzione di otto ore per sessione. Il tetto rigido di quindici minuti di Lambda costringe o a un agente a vita molto breve o a un meccanismo di continuazione costruito a mano tra invocazioni per qualsiasi cosa che giri più a lungo, cosa che un loop di diagnosi multi-tool con retry può fare. Il modello di sessione di AgentCore Runtime non ha bisogno di questo workaround.
  • Isolamento completo delle sessioni tra invocazioni, senza dover costruire a mano una sandbox per sessione a partire da execution context Lambda separati o task ECS.
  • Fatturazione al secondo senza addebito per CPU idle. 0,0895 $ per vCPU-ora e 0,00945 $ per GB-ora, fatturati al secondo, con un floor di memoria di 128MB, e in particolare nessun addebito per il tempo che un agente passa bloccato in attesa di una risposta LLM o di una chiamata a un tool, che tipicamente è dal 30 al 70 percento del tempo reale di una sessione. Un task ECS o una Lambda provisioned fatturerebbero quel tempo idle a prescindere che la CPU stia facendo qualcosa o meno.

Il compromesso è un vincolo reale, non una nota a margine: AgentCore Runtime supporta solo il set di istruzioni ARM64, quindi qualsiasi dipendenza con estensioni compilate deve risolversi in una wheel ARM64 o manylinux, altrimenti il deployment fallisce la validazione dell'header ELF al momento dell'upload. Le due dipendenze di questo agente, strands-agents e bedrock-agentcore, oggi non spediscono estensioni compilate, quindi qui non morde. Morderà il primo agente di questa serie che avrà bisogno di qualcosa come un driver di database nativo, e a quel punto la soluzione è costruire l'artefatto di deployment con pip install --platform manylinux2014_aarch64 --only-binary=:all: invece di un semplice pip install.

Terraform per il runtime

aws_bedrockagentcore_agent_runtime supporta due tipi di artefatto mutuamente esclusivi sotto agent_runtime_artifact: code_configuration, uno zip S3 con una versione runtime e un entry point, oppure container_configuration, un URI di immagine ECR. Questo modulo usa code_configuration. La CLI stessa di AgentCore imposta di default i nuovi agenti sullo stesso percorso di deploy diretto del codice (chiamato internamente CodeZip) invece che su una build container, e con esattamente due dipendenze e nessun pacchetto di sistema da installare, un Dockerfile sarebbe una cerimonia di cui questo agente non ha ancora bisogno. Un agente futuro le cui dipendenze richiedano compilazione, o il cui runtime non sia Python, è il trigger per passare a container_configuration, non una preferenza di default per un tipo di artefatto rispetto all'altro.

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" invece di un attachment VPC è deliberato: le uniche chiamate in uscita di questo agente sono l'invocazione del modello Bedrock e sts:AssumeRole cross-account, entrambi endpoint pubblici di servizi AWS raggiunti via HTTPS autenticato IAM. Non c'è nessuna risorsa di rete privata, un database o un load balancer interno, con cui questo agente parli direttamente, quindi un attachment VPC aggiungerebbe costi NAT e endpoint per nulla. La permission policy del ruolo di esecuzione è delimitata in tre direzioni che rispecchiano le tre cose che questo agente può effettivamente toccare: bedrock:InvokeModel sull'ARN dell'inference profile e sugli ARN dei foundation model sottostanti verso cui può instradare (entrambi, perché l'inferenza cross-region autorizza sia a livello di profilo sia a livello di modello di destinazione, e omettere il secondo grant fa fallire solo le invocazioni che per caso instradano verso una regione senza di esso, esattamente il fallimento dall'aspetto intermittente che la Parte 2 ha segnalato per l'accesso ai modelli stesso); sts:AssumeRole delimitato all'ARN esatto di ops-readonly in ogni account spoke che questo agente può interrogare, costruito da una lista, non da un wildcard; e s3:GetObject sul proprio prefisso di artefatto codice, niente di più ampio.

Una prima diagnosi, dall'allarme al riepilogo

Un allarme CloudWatch su AWS/ApplicationELB TargetResponseTime scatta per il servizio checkout in un account spoke. L'agente supervisore non esiste ancora (Parte 5), quindi per questa parte un umano incolla il contesto dell'allarme direttamente nell'agente. Ciò che torna, in forma condensata:

> cloudwatch_read(spoke_account_id="222222222222",
    namespace="AWS/ApplicationELB", metric_name="TargetResponseTime",
    dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
    lookback_minutes=30)
< 30 datapoints, p-average rising from 0.14s to 4.6s over the last 12 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)
< 5XX count flat near zero for 18 minutes, then rising with 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 rows, dominant message: "HikariPool-1 - Connection is not available,
  request timed out after 30000ms"

Il riepilogo dell'agente: AWS/ApplicationELB TargetResponseTime su app/prod-checkout è salito da 140ms a 4,6s in 12 minuti, con HTTPCode_Target_5XX_Count che ha iniziato a salire circa sei minuti dopo l'inizio dell'aumento di latenza. CloudWatch Logs Insights contro /aws/ecs/prod-checkout nella stessa finestra restituisce 41 righe corrispondenti a un timeout del connection pool, causa radice più probabile: il connection pool del database del servizio è esaurito, e le richieste si accodano finché non raggiungono il timeout del pool di 30 secondi, il che è ciò che si manifesta come TargetResponseTime elevato prima di diventare un vero e proprio 5XX. Confidenza: moderata, non alta, perché questo agente non ha nessun tool che possa leggere il conteggio delle connessioni del database stesso o il massimo configurato del pool, quindi non può confermare che il pool sia effettivamente pieno rispetto a, per esempio, una dipendenza a valle che tiene le connessioni aperte più a lungo del solito. Controllo successivo raccomandato: le metriche di connessione al database dell'agente runbook, una volta che quell'agente esisterà, oppure un umano che controlla direttamente il conteggio attuale delle connessioni attive del pool.

Quest'ultimo paragrafo è la dimostrazione che le regole del system prompt funzionano come previsto, non una cautela aggiunta per il post. L'agente ha evidenze reali per un'ipotesi reale, cita le metriche esatte e la query che ci stanno dietro, e si ferma esattamente al limite di ciò che i suoi due tool possono effettivamente vedere, nominando il gap invece di indovinare attraverso di esso.

Modalità di fallimento da tenere d'occhio

Tre cose che vale la pena sapere prima che questo agente giri contro un allarme reale. Primo, un fallimento di AssumeRole contro ops-readonly, ID account sbagliato, external ID scaduto, uno spoke non ancora onboardato, deve emergere come "non sono riuscito a leggere questo account" nel riepilogo finale, non come una chiamata a tool silenziosamente saltata che il modello aggira ragionando dall'unica fonte di dati che è riuscita; una diagnosi parziale presentata con la stessa confidenza di una completa è peggio di nessuna diagnosi. Secondo, il timeout di diciotto secondi di Logs Insights è un tetto reale: una query contro un log group ad alto volume con un filtro ampio può legittimamente richiedere più tempo, e la soluzione è una query più mirata (finestra temporale più stretta, filtro più specifico) da parte del modello, non un timeout più lungo che trasforma una chiamata a tool bloccata in un'invocazione dell'agente bloccata. Terzo, una temperature bassa riduce la varianza di formulazione ma non garantisce che il modello chiami entrambi i tool prima di rispondere; la regola uno del system prompt è l'effettivo meccanismo di enforcement, e vale la pena testarla contro allarmi deliberatamente progettati per tentare una scorciatoia, un allarme il cui solo nome implica fortemente una causa, per confermare che l'agente interroghi comunque prima di concludere.

Leggi questo dopo

Il file completo agent.py e il modulo completo terraform/10-agent-runtime/, inclusi i documenti di policy IAM tagliati dagli snippet sopra, vivono nel repository di accompagnamento su github.com/flightlesstux/agents-on-call. Per il lato database e infrastruttura di come far girare piattaforme come questa su scala, gli appunti tecnici sono su ercan.cloud, e l'hub è su ercanermis.com.