4부가 에이전트 하나로 끝난 자리에 이제 네 개가 존재한다. 수퍼바이저, 런북 에이전트, 비용 에이전트가 인시던트 트리아지에 합류하며, 이들은 한 프로세스 안의 파이썬 함수 호출이 아니라 AgentCore Runtime 자체의 InvokeAgentRuntime API로 조율된다. 넷 모두 여전히 분리되고 격리된 Runtime 리소스로 배포되기 때문이며, 애초에 3부가 AgentCore Runtime을 선택한 이유였던 바로 그 격리다. AWS 리소스 하나가 더 이들을 묶는다. 액터 ID로 넷이 공유하는 단일 AgentCore Memory 인스턴스로, 트리아지가 새벽 3시에 기록한 진단이 몇 초 뒤 인계받는 런북 에이전트에게도, 다음 달 이번 것과 비슷한 모양의 인시던트를 들여다보는 어떤 에이전트에게도 여전히 읽을 수 있는 상태로 남는다.

1부는 AgentCore와 Strands를 고르고 수퍼바이저 하나 뒤의 에이전트 네 개를 목표 형태로 스케치했다. 2부는 계정 경계와, 이후 모든 에이전트가 재사용해온 ops-readonly/ops-mutate 분리를 구축했다. 3부는 도구 두 개를 자기 프로세스에 직접 배선한 첫 에이전트를 내놓았다. 4부는 그 도구들에 두 번째 읽기 도구와 플랫폼의 유일한 변경 도구를 더해 AgentCore Gateway 뒤로 옮겼다. 에이전트마다 손으로 배선할 네 가지 대신, MCP 클라이언트 하나가 발견할 수 있는 Lambda 네 개다. 이번 편은 그 투자가 호출자 하나를 넘어 되갚기 시작하는 지점이다. 런북과 비용 에이전트 둘 다 같은 Gateway에 연결해, 크로스 계정 자격 증명 처리를 무엇으로부터도 다시 도출하지 않고 도구 네 개를 전부 물려받는다. 동반 코드는 github.com/flightlesstux/agents-on-callagents/supervisor/, agents/runbook/, agents/cost_optimizer/, 그리고 이번 편이 terraform/10-agent-runtime/에 추가한 부분에 있으며, 아래의 모든 스니펫은 글을 위해 단순화한 것이 아니라 그 파일들에서 그대로 발췌했다.

에이전트 하나로 더는 충분하지 않은 순간

3부의 트리아지 에이전트는 시스템 프롬프트 하나, 도구 두 개, 임무 하나였다. 4부의 도구 네 개까지는 그것으로 충분했다. 네 도구 모두 여전히 그 하나의 임무, 진단만 하고 절대 복구하지 않는 임무를 섬겼기 때문이다. 두 번째 에이전트를 실제로 강제하는 압력은 도구 수가 아니라 임무 수다. 런북 에이전트(진단을 제안된 SSM Automation 실행으로 바꾸는 일)와 비용 에이전트(하루 한 번, 누가 시키지 않아도 Cost Explorer를 읽고 라이트사이징을 제안하는 일)는 트리아지 임무의 변형이 아니라 서로 다른 임무다. 일정이 다르고, 하나의 시스템 프롬프트에 밀어 넣으면 같은 컨텍스트 윈도우 안에 모순된 지시가 함께 살게 된다. "너는 절대 변경 액션을 취하지 않으며, 그럴 수 있는 도구도 없다"(3부 이후 바뀌지 않은 트리아지 자신의 규칙)와 "매칭된 런북 청크가 뒷받침하면 ssm_execute 호출을 제안하라"(런북 에이전트의 실제 임무)는 한 프롬프트가 깔끔하게 함께 말할 수 있는 문장이 아니다. 둘 다 하라고 요구받은 에이전트는 한 임무의 규율을 다른 임무의 행동 의지 쪽으로 흘려보내거나, 첫 임무의 신중함 아래 둘째 임무의 뉘앙스를 잃는다. 프롬프트 섹션이 아니라 프로세스 단위로 일을 나누는 것이, 두 편이 지난 지금도 트리아지 자신의 규율을 읽을 수 있게 유지하는 방법이다. 그 시스템 프롬프트는 3부 이후 한 줄도 바뀌지 않았다.

컨텍스트 비대는 같은 압력의 더 조용한 버전이다. Cost Explorer의 별난 구석과 런북 코퍼스의 검색 동작까지 짊어진 트리아지 에이전트는, 대부분의 경우 해당 호출에서 하지도 않을 임무에 시스템 프롬프트 토큰과 도구 스키마 무게를 쓴다. 각자 자기 임무에 필요한 것만 쥔 에이전트 네 개는 조직적 결정인 만큼 토큰 효율 결정이기도 하다.

도구로서의 에이전트, 분리된 Runtime 네 개에 맞는 형태

Strands Agents 1.0은 프로덕션 릴리스에서 새 멀티 에이전트 프리미티브 네 개와 Agent-to-Agent 프로토콜 지원을 실었고, SDK 안에서 "도구로서의 에이전트"는 실재하는 형태가 하나가 아니다. 자식 Agent 인스턴스를 부모의 tools=[...] 목록에 직접 넘기는 것도, 도구 스펙과 함께 노출해주는 데코레이터로 감싸는 것도 둘 다 잘 동작하며, 둘 다 같은 것을 요구한다. 서브 에이전트가 호출자와 같은 파이썬 프로세스 안에 살아야 한다는 것이다. 이 플랫폼의 형태는 그렇지 않다. 트리아지, 런북, 비용은 각각 자기만의 aws_bedrockagentcore_agent_runtime 리소스로 배포되고, 독립적으로 버전이 관리되며, 독립적으로 IAM 범위가 지정되고, 3부가 AgentCore Runtime을 콕 집어 선택한 이유인 세션 격리 안에 각자 산다. 인프로세스 지름길을 쓰려고 Runtime 세 개를 프로세스 하나로 접는 것은 문법적 편의를 위해 그 격리를 되무르는 일이다.

형태동작 방식이 플랫폼에 맞는가?
tools=[agent_instance]Strands Agent 객체를 부모 자신의 도구 목록에 그대로 넘긴다아니다: 서브 에이전트가 같은 프로세스에 있어야 하고, 분리된 Runtime 세 개의 격리를 하나로 접는다
.as_tool()인프로세스 에이전트 호출을 도구 스펙을 노출하는 데코레이터로 감싼다아니다, 같은 이유다: 여전히 프로세스 하나, 죽으면 블라스트 반경도 하나다
InvokeAgentRuntime을 호출하는 @tool이미 배포된 다른 에이전트의 Runtime을 네트워크로 호출하는 평범한 Strands 도구 함수맞다: 3부의 테라폼이 이미 약속한 분리된 Runtime 네 개 형태와 일치한다

그래서 수퍼바이저는 세 번째 형태를 쓴다. 각 스페셜리스트의 런타임 ARN에 대해 bedrock-agentcoreInvokeAgentRuntime 데이터 플레인 오퍼레이션을 호출하는, @tool이 붙은 함수들이다. 트레이드오프는 실재하며 숨기는 대신 이름 붙일 가치가 있다. 인프로세스 호출은 함수 호출이지만, 이것은 자기만의 지연 시간과 도중에 실패하는 자기만의 방식을 가진 네트워크 홉이다. 우연이 아니라 의도적으로, 3부가 이미 약속한 격리의 값으로 치른 것이다:

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

잠시 멈춰볼 세부 사항이 둘 있다. 첫째, _invoke_specialist는 200이 아닌 상태나 오류 본문에 대해 평범한 발견처럼 읽히는 문자열을 반환하는 대신 예외를 던진다. "런북이 아무것도 찾지 못했다"와 "런북 호출이 실패했다"를 구분하지 못하는 수퍼바이저는 사람에게 전하는 말에 거짓 확신을 합성해 넣게 된다. 둘째, _session_id는 스페셜리스트 호출마다가 아니라 수퍼바이저 호출당 한 번 발급되어 모든 스페셜리스트에게 그대로 전달된다. AgentCore Runtime이 세 스페셜리스트의 트레이스를 인시던트 하나 아래 묶을 수 있게 해주는 것이자, 아래의 공유 메모리가 한 인시던트의 이벤트를 다른 인시던트의 것과 구분할 수 있게 해주는 것이다.

런북 에이전트: SSM 문서 위의 Knowledge Base, 제안만 하고 실행하지 않는다

런북 에이전트의 임무 전부는 진단을 후보 복구안으로 바꾸는 것이며, 그 출처는 지어낸 것이 아니라 실제 문서다. 도구 소스 두 개로 거기에 도달한다. 런북 코퍼스 위에 구축된 Bedrock Knowledge Base를 검색하는 새 kb_retrieve 도구와, Gateway가 이미 인덱싱한 모든 도구다. 후자는 손으로 선언하는 대신 연결 시점에 발견된다:

@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()는 Gateway의 MCP 엔드포인트로 가는 모든 요청을 런타임 컨테이너 자신의 실행 역할 자격 증명으로 SigV4 서명한다. Gateway 리소스 자체에 authorizer_type = "AWS_IAM"을 고른 4부의 선택과 맞물리는 부분이며, 그 IAM 역할이 닿을 수 있는 도구가 무엇이든 반환한다. cloudwatch-read, logs-read, cost-read, ssm-execute, 4부가 만든 것과 동일한 넷이고, 이 에이전트 자신의 코드 어디에도 나열되거나 참조되지 않는다. 모델이 실제로 ssm-execute를 호출하느냐는 코드 시점이 아니라 시스템 프롬프트와 IAM의 결정이다. 그리고 ssm-execute는 4부 이후 바뀌지 않았다. 여전히 모든 호출에 diagnosisblast_radius를 요구하고, 여전히 Step Functions 승인 흐름을 시작할 뿐이며, 스포크 계정 액션을 직접 시작하는 일은 결코 없다. 런북 에이전트의 시스템 프롬프트는 그것을 암묵적 규칙이 아니라 강한 규칙으로 만든다. ssm_execute 호출은 모델 자신의 텍스트가 얼마나 확신에 차 있든, 수퍼바이저에게 "완료"가 아니라 "사람에게 전송됨"으로 보고된다.

비용 에이전트: 같은 게이트로 들어가는 예약된 스윕

비용 에이전트는 페이저가 깨우지 않는 유일한 스페셜리스트다. EventBridge Scheduler가 어떤 알람의 재촉도 없이 스포크마다 하루 한 번 호출하며, 그 일정 자체가 독스트링 속 주장으로 남는 대신 전부 테라폼으로 정의된다:

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

    ...
  }
}

런북과 같은 Gateway 연결을 통해 읽고(cost-read에 더해, 지출 이상을 플래그하기 전에 실제 부하와 대조해 확인하기 위한 cloudwatch-read와 logs-read), 라이트사이징 액션이 타당해 보이면 런북이 쓰는 것과 동일한 ssm-execute 경로로 제안한다. 승인 파이프라인 하나에 제안자 둘이다. Slack을 검토하는 사람은 대기 중인 제안이 인시던트 진단에서 왔는지 밤사이 스윕에서 왔는지 알 필요도, 신경 쓸 필요도 없어야 한다.

의도적으로 설계에 넣을 가치가 있는 한 가지는 거부된 건을 다시 문제 삼지 않는 것이다. 어제에 대한 기억이 없는 일일 스윕은 사람이 승인하거나 거부에 지칠 때까지 매일 아침 같은 "유휴" 인스턴스를 플래그하며, 어느 결말도 좋지 않다:

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: 공유되는 인시던트 하나, 기록자 넷

AgentCore Memory는 단기 이벤트를 CreateEvent 호출 1,000건당 0.25달러, 장기 검색을 RetrieveMemoryRecords 호출 1,000건당 0.50달러로 과금한다. 에이전트 네 개가 호출마다 이벤트 하나씩 써도 모델 호출 한 번의 토큰 가격 옆에서는 반올림 오차 수준으로 싸다. 여기서 일을 하는 전략은 AWS 내장 타입 중 하나인 EPISODIC이다. 각 세션을 구조화된 에피소드(컨텍스트, 추론, 액션, 결과)로 포착하고, 이 플랫폼이 그 추출을 직접 프롬프트 엔지니어링할 필요 없이 에피소드들을 가로질러 더 넓은 패턴을 뽑아내는 자체 리플렉션 단계를 돌린다. aws_bedrockagentcore_memory_strategyEPISODIC 타입에 대한 테라폼 지원은 이번 편 자체 날짜보다 거의 두 달 앞선 2026년 4월 29일에 실렸다:

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}"]
}

모든 에이전트가 같은 두 함수를 통해 그 하나의 메모리에 기록하며, 독립적으로 배포된 Runtime 네 개가 공유 상태를 읽고 쓰는 방식에서 서로 어긋나지 않게 지켜주는 것이 바로 이것이다:

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"]

의도적으로 하지 않는 것이 둘 있다. 메모리 ID가 설정되지 않았을 때 예외를 던지는 대신 None을 반환하므로, 프로비저닝된 메모리 리소스 없이 로컬 테스트 중인 에이전트는 빠진 환경 변수 하나 때문에 진단을 실패시키는 대신 "공유 메모리 없음"으로 강등된다. 그리고 actor_id는 에이전트가 넘겨주는 문자열이지 이 함수가 무언가와 대조해 검증하는 값이 아니며, 이는 일부러다. 나중에 다섯 번째 에이전트를 추가해도 여기 스키마 변경은 필요 없고, 같은 네임스페이스 형태에 기록하는 새 액터 하나면 된다.

솔직하게: 멀티 에이전트가 과잉인 경우

AgentCore Runtime 네 개에 메모리 리소스 하나, 크로스 런타임 네트워킹까지, 더 작은 운영 팀이라면 도구 일곱 개와 더 긴 시스템 프롬프트를 가진 에이전트 하나로 돌릴 수 있는 일을 위한 실제 인프라다. AgentCore Runtime은 vCPU-시간당 0.0895달러, GB-시간당 0.00945달러를 과금하고 유휴 시간은 무료라서 작은 런타임 네 개가 절대 액수로 비싸지는 않지만, 배포할 것 네 개, 감사할 IAM 역할 네 개, 그리고 단일 프로세스에는 결코 없는 InvokeAgentRuntime 홉 한 번어치의 지연 시간과 실패 표면이다. 분리의 솔직한 트리거는 "이 플랫폼이 커졌다"가 아니라 이번 편이 첫머리에서 연 바로 그것이다. 하나의 시스템 프롬프트 안에서 지시가 서로 모순되는 별개의 임무들, 또는 자연스러운 단일 루프를 공유하지 않는 별개의 일정들(알람 구동 에이전트와 재촉 없는 일일 스윕)이다. 임무가 하나뿐인, 트리아지 말고는 아무것도 없는 운영 플랫폼은 그 압력을 전혀 받지 않으며 좋은 도구를 가진 에이전트 하나로 남아야 한다. 따져볼 크로스 런타임 호출도, 낡지 않게 지킬 메모리 리소스도, 하나여도 될 역할 셋을 조율하는 for_each 테라폼 모듈도 없다. 두 번째 진짜 임무가 존재하기 전에 일찍 쪼개는 것은, 아직 아무도 필요로 하지 않은 격리를 아직 아무도 원하지 않은 네트워크 홉의 값을 치르고 사는 일이다.

지켜봐야 할 실패 모드

실제 인시던트를 상대로 돌아가기 전에 이름 붙일 가치가 있는 것이 넷이다. 트리아지는 아직 공유 메모리에 기록하지 않는다. 3부의 코드는 이번 편의 메모리 리소스보다 앞서고, 이번 편의 무엇도 그것을 소급 적용하지 않으므로, 리플렉션 전략의 에피소드에는 거의 모든 인시던트에서 가장 먼저 도는 그 한 에이전트가 빠져 있다. 얼버무리고 넘어갈 누락이 아니라 실제 공백이다. 도중에 실패하는 크로스 런타임 호출은 인프로세스 호출과는 다른 실패다. 인시던트 중간에 _invoke_specialist가 예외를 던지면 수퍼바이저가 이미 메모리에 기록한 것이 부분 타임라인으로 남는다. 손으로 인시던트를 이어받는 사람에게는 유용하지만, 아무도 확인하지 않으면 완전한 것으로 오독되기 쉽다. EPISODIC의 리플렉션 단계는 닫힌 세션 위에서 비동기로 돌기 때문에 retrieve_incident_context가 빈 목록을 반환한다는 것은 "정말로 새롭다"일 수도 "리플렉션이 아직 돌지 않았다"일 수도 있으며, 현재 이 코드도 에이전트의 프롬프트도 둘을 구분하지 못한다. 그리고 새 에이전트들의 IAM 정책에는 실제 순서 의존성이 있다. 수퍼바이저의 역할 정책이 런북과 비용 런타임 자신의 ARN을 참조하므로, 그 런타임들의 프로비저닝과 수퍼바이저 역할의 프로비저닝 사이에서 중단된 테라폼 apply는 다음 plan에서 수퍼바이저의 정책을 아직 적용되지 않은 것으로 보여준다. 지속되는 잘못된 설정은 아니지만, 부분 apply를 믿는 대신 apply가 끝나게 두어야 할 이유다.

다음으로 읽을 글

전체 agents/supervisor/, agents/runbook/, agents/cost_optimizer/와 그 뒤의 메모리, 런타임 테라폼은 동반 저장소인 github.com/flightlesstux/agents-on-call에 있다. 이런 플랫폼을 대규모로 운영할 때의 데이터베이스와 인프라 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.