Agents on Call, Parte 3. Primeiro Agente: Triagem de Incidentes em Strands
O primeiro agente entra em produção: triagem Strands no AgentCore Runtime, system prompt evidence-first, duas tools somente leitura e Terraform.

O primeiro agente funcional desta série tem cerca de 260 linhas de Python: um Agent do Strands, duas tools somente leitura que assumem uma role IAM entre contas antes de chamar o boto3, e um system prompt cujo único trabalho é impedir que o modelo soe confiante sobre algo que na verdade não verificou. Ainda sem AgentCore Gateway, sem supervisor, sem handoff multi-agente: isso vem nas partes seguintes. Esta parte é sobre fazer um agente executar um trabalho corretamente, implantado no AgentCore Runtime, antes de adicionar qualquer coisa que torne o debugging mais difícil.
A Parte 1 estabeleceu o cenário (uma empresa SaaS com 50 engenheiros, 40 chamados por semana, somente leitura imposto pelo IAM como restrição não negociável) e escolheu AgentCore mais Strands em vez dos Bedrock Agents clássicos ou de um loop construído do zero. A Parte 2 construiu a fronteira de conta em que esses agentes vivem: as roles de spoke ops-readonly e ops-mutate, e um application inference profile por agente envolvendo o roteamento entre regiões do Bedrock. Esta parte usa ambos diretamente. Nada aqui é provisionado do zero; ela consome o que a Parte 2 já construiu. O código complementar vive em github.com/flightlesstux/agents-on-call, em agents/triage/agent.py e terraform/10-agent-runtime/, e todo trecho abaixo é extraído diretamente desses arquivos, não simplificado para o post.
Anatomia de um agente Strands
Strands Agents é um SDK Python orientado a modelo, aberto pela AWS em maio de 2025 sob a licença Apache 2.0 e usado internamente pelo Amazon Q Developer, AWS Glue e VPC Reachability Analyzer antes mesmo de sair de dentro de casa. A proposta não é uma nova linguagem de orquestração: é um loop fino em torno de um modelo que já sabe chamar tools, conectado ao Bedrock, à Anthropic direta, ao Ollama ou a um provedor via proxy LiteLLM sem mudar como o agente é escrito. Toda a forma de um agente cabe em cinco passos.
1. A configuração vem do ambiente, não de literais no arquivo. Tudo o que o runtime precisa saber é definido como variável de ambiente pelo módulo Terraform abaixo, então nada específico de conta ou secreto fica hardcoded no código-fonte:
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. Tools são funções Python simples com um decorator. O Strands lê os type hints e a docstring de uma função para construir a spec de tool que o modelo de fato vê, então a docstring não é um comentário para humanos, é o contrato de 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. O modelo é conectado através do inference profile da Parte 2, não de um model ID puro. A temperature fica baixa de propósito: este agente propõe um diagnóstico a partir de evidências, ele não precisa de variedade criativa na formulação.
model = BedrockModel(
model_id=INFERENCE_PROFILE_ARN,
region_name=AWS_REGION,
temperature=0.1,
max_tokens=2048,
)4. O agente em si é uma única chamada de construtor: um modelo, um system prompt e uma lista de tools. Não há nenhum grafo de orquestração para escrever, porque o Strands infere o loop de chamada de tools a partir da própria saída de uso de tools do modelo:
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. Um entrypoint do AgentCore Runtime envolve tudo isso. BedrockAgentCoreApp vem do pacote separado bedrock-agentcore, não do próprio Strands: o Strands é o framework de agentes, o AgentCore é a camada de hospedagem, e o decorator @app.entrypoint é a costura entre os dois.
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()Chamar agent(prompt) é o loop inteiro do agente visto de fora: o Strands envia o prompt e as specs de tools para o Bedrock, recebe de volta uma resposta final ou um pedido de uso de tool, executa a tool solicitada, devolve o resultado, e repete até o modelo parar de pedir tools. Nada desse loop é código que este projeto possui; é código que este projeto teria tido que escrever à mão sob a opção de construção própria que a Parte 1 rejeitou.
Design do system prompt: evidência antes de conclusões
Um agente de triagem que soa confiante é mais perigoso do que um que diz "ainda não sei", porque um humano lendo sua saída às três da manhã age tanto pelo tom quanto pelo conteúdo. O system prompt existe quase inteiramente para prevenir esse modo de falha, não para tornar o agente mais inteligente:
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.A regra dois é a que faz mais trabalho. "Cite o nome da métrica" soa como uma preferência estilística, mas é o que transforma a saída do agente de uma afirmação que um humano precisa confiar numa afirmação que um humano consegue verificar em quinze segundos colando a mesma query do CloudWatch. A regra cinco existe porque a falha natural de um agente com orçamento de retry é continuar ampliando a janela de tempo ou mudando a query até achar alguma coisa, momento em que essa "alguma coisa" é reportada como a causa mesmo quando não tem relação nenhuma. Um resultado vazio é dado. Tratá-lo como um beco sem saída a reportar, em vez de um convite para continuar pescando, é o que impede o agente de fabricar um diagnóstico a partir de ruído.
Duas tools, Python puro, ainda sem Gateway
A arquitetura-alvo da Parte 1 coloca toda tool atrás do AgentCore Gateway: compatível com MCP, com escopo de IAM centralizado, uma allowlist para quatro tools em toda a plataforma. Isso é a Parte 4. Aqui, com exatamente um agente e duas tools, o Gateway seria indireção sem nada ainda para rotear entre si. Ambas as tools são funções Python simples decoradas com @tool que o agente chama diretamente, e ambas fazem a mesma coisa antes de tocar na AWS: assumir a ops-readonly na conta spoke alvo.
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 minutos, não o teto de uma hora que a trust policy da ops-readonly permite: uma chamada de tool que ainda precisa de credenciais depois de quinze minutos está travada em outra coisa, e uma credencial de vida mais curta reduz o raio de impacto caso ela algum dia vaze do processo. cloudwatch_read envolve get_metric_statistics e retorna os datapoints como JSON, do mais antigo para o mais recente. logs_read inicia uma query do CloudWatch Logs Insights e faz polling até a conclusão, limitado a dezoito segundos ao longo de vinte tentativas, e levanta uma exceção em vez de retornar um resultado vazio numa query travada: uma log query que expira silenciosamente e reporta zero linhas correspondentes lê exatamente como "nenhum erro encontrado", que é o único modo de falha que esta tool não pode ter. Ambas as tools aceitam um argumento spoke_account_id, o que significa que o modelo decide qual conta consultar com base no que o evento de alarme diz a ele, não um valor fixado no deployment.
O que o AgentCore Runtime compra em relação a Lambda ou ECS
Nada aqui exige estritamente o AgentCore Runtime. Um agente Strands é Python comum; poderia rodar numa função Lambda ou numa task ECS com um client boto3 e um loop. O AgentCore Runtime, em disponibilidade geral desde 13 de outubro de 2025, compra três coisas que de outra forma seriam infraestrutura customizada:
- Uma janela de execução de oito horas por sessão. O teto rígido de quinze minutos da Lambda força um agente de vida muito curta ou um mecanismo de continuação construído à mão entre invocações para qualquer coisa que rode mais tempo, o que um loop de diagnóstico com múltiplas tools e retries pode fazer. O modelo de sessão do AgentCore Runtime não precisa desse contorno.
- Isolamento completo de sessão entre invocações, sem construir um sandbox por sessão à mão a partir de execution contexts separados da Lambda ou tasks ECS.
- Cobrança por segundo, sem cobrança por CPU ociosa. $0,0895 por vCPU-hora e $0,00945 por GB-hora, cobrados ao segundo, com um piso de 128MB de memória, e especificamente sem cobrança pelo tempo que um agente passa bloqueado esperando uma resposta do LLM ou uma chamada de tool, o que tipicamente é de 30 a 70 por cento do tempo de relógio de uma sessão. Uma task ECS ou uma Lambda provisionada cobrariam por esse tempo ocioso independentemente de a CPU estar fazendo alguma coisa.
O trade-off é uma restrição real, não uma nota de rodapé: o AgentCore Runtime só suporta o conjunto de instruções ARM64, então qualquer dependência com extensões compiladas precisa resolver para um wheel ARM64 ou manylinux, ou o deployment falha na validação de ELF-header no momento do upload. As duas dependências deste agente, strands-agents e bedrock-agentcore, não empacotam extensões compiladas hoje, então isso não morde aqui. Vai morder o primeiro agente desta série que precisar de algo como um driver de banco de dados nativo, e o conserto nesse ponto é construir o artefato de deployment com pip install --platform manylinux2014_aarch64 --only-binary=:all: em vez de um pip install simples.
Terraform para o runtime
aws_bedrockagentcore_agent_runtime suporta dois tipos de artefato mutuamente exclusivos sob agent_runtime_artifact: code_configuration, um zip no S3 com uma versão de runtime e um entry point, ou container_configuration, uma URI de imagem ECR. Este módulo usa code_configuration. A própria CLI do AgentCore usa por padrão o mesmo caminho de deploy direto de código (chamado internamente de CodeZip) em vez de um build de container para agentes novos, e com exatamente duas dependências e nenhum pacote de sistema para instalar, um Dockerfile seria cerimônia que este agente ainda não precisa. Um agente futuro cujas dependências precisem compilar, ou cujo runtime não seja Python, é o gatilho para trocar para container_configuration, não uma preferência padrão por um tipo de artefato sobre o outro.
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" em vez de um attachment de VPC é deliberado: as únicas chamadas de saída deste agente são invocação de modelo do Bedrock e sts:AssumeRole entre contas, ambos endpoints públicos de serviço da AWS alcançados por HTTPS autenticado por IAM. Não há nenhum recurso de rede privada, um banco de dados ou um load balancer interno, com o qual este agente fale diretamente, então um attachment de VPC adicionaria custo de NAT e de endpoint por nada. A permission policy da execution role tem escopo em três direções que espelham as três coisas que este agente pode de fato tocar: bedrock:InvokeModel no ARN do inference profile e nos ARNs dos foundation models subjacentes para os quais ele pode rotear (ambos, porque a inferência entre regiões autoriza tanto no nível do profile quanto no nível do modelo de destino, e a falta da segunda concessão falha apenas as invocações que por acaso roteiam para uma região sem ela, exatamente o tipo de falha de aparência intermitente que a Parte 2 sinalizou para o próprio acesso a modelos); sts:AssumeRole com escopo no ARN exato da ops-readonly em cada conta spoke que este agente tem permissão de consultar, construído a partir de uma lista, não de um wildcard; e s3:GetObject no seu próprio prefixo de artefato de código, nada mais amplo.
Um primeiro diagnóstico, do alarme ao resumo
Um alarme do CloudWatch em AWS/ApplicationELB TargetResponseTime dispara para o serviço de checkout numa conta spoke. O agente supervisor ainda não existe (Parte 5), então, para esta parte, um humano cola o contexto do alarme diretamente no agente. O que volta, condensado:
> 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"O resumo do agente: AWS/ApplicationELB TargetResponseTime em app/prod-checkout subiu de 140ms para 4.6s em 12 minutos, com HTTPCode_Target_5XX_Count começando a subir cerca de seis minutos depois do início da alta de latência. O CloudWatch Logs Insights contra /aws/ecs/prod-checkout na mesma janela retorna 41 linhas correspondendo a um timeout de connection pool, causa raiz mais provável: o connection pool do banco de dados do serviço está esgotado, e as requisições estão enfileirando até baterem no timeout de 30 segundos do pool, o que é o que está aparecendo como TargetResponseTime elevado antes de virar um 5XX propriamente dito. Confiança: moderada, não alta, porque este agente não tem nenhuma tool que consiga ler a contagem de conexões do próprio banco ou o máximo configurado do pool, então ele não consegue confirmar se o pool está de fato cheio versus, por exemplo, uma dependência downstream segurando conexões abertas por mais tempo que o normal. Próxima checagem recomendada: as métricas de conexão de banco de dados do agente de runbook, quando esse agente existir, ou um humano checando diretamente a contagem atual de conexões ativas do pool.
Esse último parágrafo é as regras do system prompt funcionando como pretendido, não uma ressalva adicionada para o post. O agente tem evidência real para uma hipótese real, cita as métricas e a query exatas por trás dela, e para exatamente na borda do que suas duas tools conseguem de fato enxergar, nomeando a lacuna em vez de chutar através dela.
Modos de falha a observar
Três coisas que vale a pena saber antes deste agente rodar contra um alarme real. Primeiro, uma falha de AssumeRole contra a ops-readonly, ID de conta errado, external ID expirado, um spoke ainda não integrado, precisa aparecer como "não consegui ler esta conta" no resumo final, não como uma chamada de tool silenciosamente pulada que o modelo contorna raciocinando a partir da única fonte de dados que teve sucesso; um diagnóstico parcial apresentado com a mesma confiança de um completo é pior do que nenhum diagnóstico. Segundo, o timeout de dezoito segundos do Logs Insights é um teto real: uma query contra um log group de alto volume com um filtro amplo pode legitimamente demorar mais, e o conserto é uma query mais restrita (janela de tempo mais estreita, filtro mais específico) vindo do modelo, não um timeout maior que transforma uma chamada de tool travada numa invocação de agente travada. Terceiro, temperature baixa reduz a variância na formulação mas não garante que o modelo chame as duas tools antes de responder; a regra um do system prompt é o mecanismo de imposição de fato, e vale a pena testar contra alarmes deliberadamente projetados para tentar um atalho, um alarme cujo próprio nome já sugere fortemente uma causa, para confirmar que o agente ainda consulta antes de concluir.
Leia isso a seguir
- Parte 2, A Fundação: Terraform Antes dos Tokens, para a role
ops-readonlye o application inference profile que este agente consome diretamente. - Parte 4, Tools e o Gateway: MCP, Allowlists, Somente Leitura por Padrão, onde essas mesmas duas tools, mais duas outras, se movem para trás do AgentCore Gateway e recebem IAM com escopo centralizado em vez de serem conectadas uma função Python de cada vez.
- Automating AWS CloudWatch Log Group Tagging with Python and Boto3 no ercan.cloud, o mesmo padrão de boto3 contra o CloudWatch que as tools desta parte usam, do lado da automação simples em vez do lado do agente.
O agent.py completo e o módulo terraform/10-agent-runtime/ completo, incluindo os documentos de policy IAM cortados dos trechos acima, vivem no repositório complementar em github.com/flightlesstux/agents-on-call. Para o lado de banco de dados e infraestrutura de rodar plataformas como esta em escala, as notas de campo estão em ercan.cloud, e o hub fica em ercanermis.com.
Mais de Ercan
Mais dois sites, mesmo autor, terreno diferente.
Cloud, AWS, EKS, Terraform, engenharia de plataforma.
Notas de campo de sistemas em produção. EKS, IAM, Terraform em escala organizacional, observabilidade, otimização de custos.
Visitar ercan.cloud →O hub. Sobre, consultoria, contato.
Hub pessoal para as duas trilhas de escrita. Quem sou eu, como funciona a consultoria, como me contatar.
Visitar ercanermis.com →