Memória · Sistemas Multi-Agentes

Redis & DynamoDB
Memory Architecture

Três clusters Redis com responsabilidades isoladas. DynamoDB como primário para histórico e preferências. Short-term memory restrita ao ciclo de vida da thread. Long-term memory inferida pelo LLM e persistida entre sessões. O Supervisor como único dono do estado — agentes recebem contexto via MCP payload.

Visão Geral da Arquitetura

Todo o estado vive exclusivamente na Conta Supervisora. Os agentes especialistas e os BFFs são stateless — recebem contexto enriquecido no payload MCP e devolvem resultados. Isso elimina acoplamento de estado entre contas e torna cada agente trivialmente escalável.

┌──────────────────────────── Supervisor Account ────────────────────────────────┐
│                                                                                 │
│  ┌──────────────────┐  ┌──────────────────┐  ┌────────────────────────────┐   │
│  │  redis-sessions  │  │  redis-threads   │  │       redis-memory         │   │
│  │                  │  │                  │  │                            │   │
│  │  Session Store   │  │  LangGraph       │  │  UserPreferences cache     │   │
│  │  User mapping    │  │  Checkpointer    │  │  LTM Summaries cache       │   │
│  │                  │  │  Short-term mem  │  │  Working memory            │   │
│  │  TTL: 2h sliding │  │  TTL: 30min      │  │  TTL: sem TTL / 30min      │   │
│  └────────┬─────────┘  └────────┬─────────┘  └──────────────┬─────────────┘   │
│           └────────────────────┴──────────────────────────────┘                │
│                                           │                                    │
│                              ┌────────────▼──────────────┐                    │
│                              │         DynamoDB           │                    │
│                              │  ConversationHistory       │                    │
│                              │  UserPreferences           │                    │
│                              │  SessionIndex              │                    │
│                              │  TTL: 90 dias              │                    │
│                              └────────────┬───────────────┘                    │
│                                           │ DynamoDB Streams                   │
│                              ┌────────────▼───────────────┐                    │
│                              │   Lambda Archiver           │                    │
│                              └────────────┬───────────────┘                    │
│                              ┌────────────▼───────────────┐                    │
│                              │   S3 Data Lake (Parquet)    │                    │
│                              └────────────────────────────┘                    │
└─────────────────────────────────────────────────────────────────────────────────┘
              ↕ apenas o Supervisor lê/escreve Redis e DynamoDB
   ┌─────────────────────────┐       ┌─────────────────────────┐
   │     Agents Account      │       │      BFFs Account        │
   │  stateless — recebe     │       │  stateless — recebe      │
   │  contexto via MCP       │       │  contexto via MCP        │
   └─────────────────────────┘       └─────────────────────────┘

Memory Layers — Short-term vs Long-term

A distinção entre short-term e long-term não é arbitrária — ela mapeia diretamente para o ciclo de vida dos dados e onde eles vivem.

⚡

Short-term Memory — Thread State

Contexto da conversa ativa. Mensagens, tool calls, estado LangGraph. Vive em redis-threads. Morre com a thread — arquivado antes de expirar.

30 min
sliding
🔄

Short-term Memory — Sessão

Mapeamento session_id ↔ user_id. Garante que múltiplas threads de uma sessão compartilhem o mesmo usuário. Vive em redis-sessions.

2 h
sliding
🧠

Long-term Memory — Session Summaries

Resumos gerados por LLM ao encerrar cada sessão. Descrevem o que foi discutido, tópicos e sentimento. Usados para enriquecer contexto de sessões futuras.

Permanente
DynamoDB
👤

Long-term Memory — UserPreferences

Fatos inferidos automaticamente pelo supervisor sobre o usuário — idioma, estilo de resposta, plano, segmento, último problema. 3 níveis: preferências simples, summaries e fatos estruturados.

Permanente
DynamoDB
Write-through para UserPreferences: DynamoDB é sempre escrito primeiro (primário). Redis é o cache de leitura rápida — populado automaticamente no primeiro acesso (read-through) e invalidado por evento na escrita.

Os Três Clusters Redis

Clusters separados permitem TTL policies, scaling e políticas de eviction independentes. Um único cluster com prefixos seria mais barato, mas não permite configurar maxmemory-policy diferente por função — e aqui isso importa: redis-memory não pode usar allkeys-lru porque preferências não têm TTL.

🔑

redis-sessions

TTL: 2h sliding
  • Session hash
  • session → user mapping
  • user → active session
  • eviction: volatile-lru
🧵

redis-threads

TTL: 30min sliding
  • LangGraph checkpoint
  • Thread state hash
  • Messages list
  • eviction: volatile-lru
💾

redis-memory

TTL: event-driven
  • UserPreferences cache
  • LTM summaries list
  • Working memory (30min)
  • eviction: volatile-lru

Key Schema — redis-sessions

redis — key schema
session:{session_id}              → Hash    TTL: 2h sliding
session:{session_id}:user         → String  TTL: 2h sliding   valor: user_id
user:{user_id}:active_session     → String  TTL: 2h sliding   valor: session_id

Estrutura do Hash session:{session_id}

json — hash fields
{
  "session_id":  "sess_abc123",
  "user_id":     "usr_xyz789",
  "started_at":  "2025-01-15T10:00:00Z",
  "last_active": "2025-01-15T10:45:00Z",
  "status":      "active",
  "metadata":    "{\"channel\": \"web\", \"ip\": \"...\"}"
}
Sliding TTL: a cada leitura de sessão, o TTL é renovado via EXPIRE em pipeline — sempre nas 3 chaves ao mesmo tempo para manter coerência.

Key Schema — redis-threads

redis — key schema
thread:{thread_id}:state          → Hash    TTL: 30min sliding
thread:{thread_id}:messages       → List    TTL: 30min sliding
thread:{thread_id}:checkpoint     → String  TTL: 30min sliding  JSON do LangGraph state
session:{session_id}:current_thread → String TTL: 2h            valor: thread_id

Estrutura do Hash thread:{thread_id}:state

json — hash fields
{
  "thread_id":   "thrd_def456",
  "session_id":  "sess_abc123",
  "user_id":     "usr_xyz789",
  "created_at":  "2025-01-15T10:05:00Z",
  "last_active": "2025-01-15T10:40:00Z",
  "status":      "active",
  "agent_route": "billing_agent",
  "turn_count":  "7"
}
Regra crítica: o ThreadArchiver deve salvar no DynamoDB antes do TTL expirar. Uma thread encerrada explicitamente dispara o archiver de forma síncrona. Para inatividade, um processo separado (Lambda via EventBridge Scheduler) varre threads com last_active > 25min e dispara o arquivamento.

Key Schema — redis-memory

redis — key schema
mem:pref:{user_id}                → Hash    sem TTL  (invalidação por write-through)
mem:ltm:{user_id}:summaries       → List    sem TTL  (LPUSH + LTRIM — top 10)
mem:wm:{thread_id}                → Hash    TTL: 30min (mesma vida da thread)

Hash mem:pref:{user_id} — UserPreferences cache

json — hash fields
{
  "language":        "pt-BR",
  "response_style":  "concise",
  "segment":         "fintech",
  "plan":            "enterprise",
  "last_updated":    "2025-01-15T10:00:00Z",
  "inferred_count":  "12"
}

Entry da List mem:ltm:{user_id}:summaries — JSON string

json — list entry (JSON string)
{
  "session_id": "sess_prev001",
  "summary":    "Usuário perguntou sobre integração via API REST. Resolvido com sucesso.",
  "topics":     ["api", "integration", "billing"],
  "sentiment":  "positive",
  "resolved":   true,
  "created_at": "2025-01-14T15:30:00Z"
}

DynamoDB — Tabelas e Índices

DynamoDB é o sistema de registro — Redis é o cache de execução. Toda escrita relevante passa pelo DynamoDB primeiro. A modelagem usa PK = USER#{user_id} para habilitar queries naturais por usuário, com SK composto para suportar múltiplos padrões de acesso na mesma tabela.

Tabela ConversationHistory

ConversationHistory TTL: 90 dias
PK USER#{user_id}
SK SESSION#{session_id}#MSG#{timestamp_iso} — mensagens ordenadas por tempo
SK SUMMARY#{session_id} — summary gerado pelo LLM ao encerrar

GSIs

Índice PK SK Caso de uso
ThreadIndex THREAD#{thread_id} MSG#{timestamp} Todas as mensagens de uma thread específica
SessionSummaryIndex USER#{user_id} SUMMARY#{session_id} Últimos N summaries de um usuário (LTM fallback)

Item — Mensagem

json — DynamoDB item
{
  "PK":          "USER#usr_xyz789",
  "SK":          "SESSION#sess_abc123#MSG#2025-01-15T10:05:00.000Z",
  "message_id":  "msg_001",
  "thread_id":   "thrd_def456",
  "role":        "user",
  "content":     "Preciso entender minha fatura",
  "agent_name":  null,
  "token_count": 8,
  "ttl":         1744924800
}

Item — Session Summary

json — DynamoDB item
{
  "PK":         "USER#usr_xyz789",
  "SK":         "SUMMARY#sess_abc123",
  "session_id": "sess_abc123",
  "summary":    "Usuário perguntou sobre integração via API REST. Resolvido.",
  "topics":     ["api", "integration"],
  "sentiment":  "positive",
  "resolved":   true,
  "turn_count": 7,
  "started_at": "2025-01-15T10:00:00Z",
  "ended_at":   "2025-01-15T10:50:00Z",
  "ttl":        1744924800
}

Tabela UserPreferences

Dados permanentes — sem TTL. Escrita por inferência do LLM ao encerrar sessão. Três níveis de granularidade coexistindo via SK diferente.

UserPreferences Permanente — sem TTL
PK USER#{user_id}
SK PREF#PROFILE — preferências gerais + nível 1
SK PREF#FACT#{fact_key} — fatos estruturados nível 3 (plan, segment, last_issue…)

Os 3 níveis de Long-term Memory

NívelExemplosOndeComo é gerado
Nível 1 Preferências simples language: pt-BR, response_style: concise PREF#PROFILE LLM infere ao encerrar thread
Nível 2 Session summaries "nas últimas 3 sessões perguntou sobre faturamento" SUMMARY# (ConversationHistory) LLM gera resumo estruturado da conversa completa
Nível 3 Fatos estruturados plan: enterprise, last_issue: webhook PREF#FACT#{key} LLM extrai fatos com confidence score

Tabela SessionIndex

SessionIndex TTL: 90 dias
PK USER#{user_id}
SK SESSION#{timestamp_iso}#{session_id} — sessions ordenadas por data

Permite listar todas as sessões de um usuário em ordem cronológica. O GSI SessionStatusIndex (PK: STATUS#{status}) habilita queries operacionais como "todas as sessões ativas agora" para monitoramento.

Implementação — Código Python

Todos os módulos abaixo são usados exclusivamente pelo Supervisor. Agentes remotos nunca importam nenhum desses módulos.

Redis Clients — Singleton dos 3 clusters

python — infrastructure/redis_clients.py
import redis.asyncio as aioredis

class RedisClients:
    """Singleton para os 3 clusters Redis."""

    def __init__(self, config: dict):
        self.sessions = aioredis.Redis(
            host=config["sessions"]["host"],
            port=config["sessions"]["port"],
            decode_responses=True,
            ssl=True,
        )
        self.threads = aioredis.Redis(
            host=config["threads"]["host"],
            port=config["threads"]["port"],
            decode_responses=True,
            ssl=True,
        )
        self.memory = aioredis.Redis(
            host=config["memory"]["host"],
            port=config["memory"]["port"],
            decode_responses=True,
            ssl=True,
        )

    async def close(self):
        await self.sessions.aclose()
        await self.threads.aclose()
        await self.memory.aclose()

Session Store

python — memory/session_store.py
import json
from datetime import datetime, timezone
from typing import Optional
from infrastructure.redis_clients import RedisClients

SESSION_TTL = 7200   # 2h
THREAD_TTL  = 1800   # 30min

class SessionStore:
    def __init__(self, redis: RedisClients):
        self.r = redis.sessions

    async def create_session(
        self, session_id: str, user_id: str, metadata: dict = {}
    ) -> dict:
        now = datetime.now(timezone.utc).isoformat()
        session = {
            "session_id":  session_id,
            "user_id":     user_id,
            "started_at":  now,
            "last_active": now,
            "status":      "active",
            "metadata":    json.dumps(metadata),
        }
        pipe = self.r.pipeline()
        pipe.hset(f"session:{session_id}", mapping=session)
        pipe.expire(f"session:{session_id}", SESSION_TTL)
        pipe.set(f"session:{session_id}:user", user_id, ex=SESSION_TTL)
        pipe.set(f"user:{user_id}:active_session", session_id, ex=SESSION_TTL)
        await pipe.execute()
        return session

    async def get_session(self, session_id: str) -> Optional[dict]:
        session = await self.r.hgetall(f"session:{session_id}")
        if not session:
            return None
        # Sliding TTL — renova nas 3 chaves ao mesmo tempo
        pipe = self.r.pipeline()
        pipe.expire(f"session:{session_id}", SESSION_TTL)
        pipe.expire(f"session:{session_id}:user", SESSION_TTL)
        user_id = session.get("user_id")
        if user_id:
            pipe.expire(f"user:{user_id}:active_session", SESSION_TTL)
        await pipe.execute()
        return session

    async def get_active_session_for_user(self, user_id: str) -> Optional[str]:
        return await self.r.get(f"user:{user_id}:active_session")

    async def touch_session(self, session_id: str) -> None:
        now = datetime.now(timezone.utc).isoformat()
        await self.r.hset(f"session:{session_id}", "last_active", now)
        await self.r.expire(f"session:{session_id}", SESSION_TTL)

    async def close_session(self, session_id: str) -> None:
        user_id = await self.r.get(f"session:{session_id}:user")
        pipe = self.r.pipeline()
        pipe.hset(f"session:{session_id}", "status", "closed")
        pipe.expire(f"session:{session_id}", 300)  # mantém 5min para cleanup
        if user_id:
            pipe.delete(f"user:{user_id}:active_session")
        await pipe.execute()

LangGraph Redis Checkpointer

python — memory/langgraph_checkpointer.py
import json
from typing import AsyncIterator
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
    BaseCheckpointSaver, Checkpoint, CheckpointMetadata, CheckpointTuple
)
from infrastructure.redis_clients import RedisClients

THREAD_TTL = 1800  # 30min

class RedisCheckpointer(BaseCheckpointSaver):
    """
    Checkpointer LangGraph usando redis-threads.
    Sliding TTL renovado a cada aput() — garante que o TTL
    reflita inatividade real, não tempo desde a criação.
    """

    def __init__(self, redis: RedisClients):
        super().__init__()
        self.r = redis.threads

    def _checkpoint_key(self, thread_id: str) -> str:
        return f"thread:{thread_id}:checkpoint"

    def _meta_key(self, thread_id: str) -> str:
        return f"thread:{thread_id}:state"

    # interface sync — não utilizada
    def get_tuple(self, config): raise NotImplementedError("Use async")
    def list(self, config, **kw): raise NotImplementedError("Use async")
    def put(self, config, checkpoint, metadata): raise NotImplementedError("Use async")

    async def aget_tuple(self, config: RunnableConfig):
        thread_id = config["configurable"]["thread_id"]
        raw = await self.r.get(self._checkpoint_key(thread_id))
        if not raw:
            return None
        # Sliding TTL ao ler
        await self.r.expire(self._checkpoint_key(thread_id), THREAD_TTL)
        await self.r.expire(self._meta_key(thread_id), THREAD_TTL)
        data = json.loads(raw)
        return CheckpointTuple(
            config=config,
            checkpoint=data["checkpoint"],
            metadata=data["metadata"],
            parent_config=data.get("parent_config"),
        )

    async def aput(
        self,
        config: RunnableConfig,
        checkpoint: Checkpoint,
        metadata: CheckpointMetadata,
    ) -> RunnableConfig:
        thread_id = config["configurable"]["thread_id"]
        import datetime as dt
        data = json.dumps({
            "checkpoint":    checkpoint,
            "metadata":      metadata,
            "parent_config": config,
        })
        pipe = self.r.pipeline()
        pipe.set(self._checkpoint_key(thread_id), data, ex=THREAD_TTL)
        pipe.hset(self._meta_key(thread_id), mapping={
            "last_active": dt.datetime.now(dt.timezone.utc).isoformat(),
            "turn_count":  str(metadata.get("step", 0)),
        })
        pipe.expire(self._meta_key(thread_id), THREAD_TTL)
        await pipe.execute()
        return config

    async def alist(self, config: RunnableConfig, **kw) -> AsyncIterator:
        result = await self.aget_tuple(config)
        if result:
            yield result

Memory Store — LTM + Working Memory

python — memory/memory_store.py
import json
from typing import Optional
from infrastructure.redis_clients import RedisClients

WM_TTL            = 1800  # Working memory: 30min
MAX_LTM_SUMMARIES = 10    # Top 10 sessões no cache

class MemoryStore:
    def __init__(self, redis: RedisClients):
        self.r = redis.memory

    # ── UserPreferences ────────────────────────────────────────────────
    async def get_preferences(self, user_id: str) -> Optional[dict]:
        """None = cache miss → Supervisor faz fallback ao DynamoDB."""
        raw = await self.r.hgetall(f"mem:pref:{user_id}")
        return raw if raw else None

    async def set_preferences(self, user_id: str, prefs: dict) -> None:
        """Chamado após escrita no DynamoDB (write-through). Sem TTL."""
        await self.r.hset(f"mem:pref:{user_id}", mapping=prefs)

    # ── LTM Summaries ──────────────────────────────────────────────────
    async def get_ltm_summaries(self, user_id: str, n: int = 5) -> list[dict]:
        raw_list = await self.r.lrange(f"mem:ltm:{user_id}:summaries", 0, n - 1)
        return [json.loads(s) for s in raw_list]

    async def push_ltm_summary(self, user_id: str, summary: dict) -> None:
        key = f"mem:ltm:{user_id}:summaries"
        pipe = self.r.pipeline()
        pipe.lpush(key, json.dumps(summary))          # mais recente na frente
        pipe.ltrim(key, 0, MAX_LTM_SUMMARIES - 1)    # mantém top 10
        await pipe.execute()

    # ── Working Memory ─────────────────────────────────────────────────
    async def get_working_memory(self, thread_id: str) -> Optional[dict]:
        raw = await self.r.hgetall(f"mem:wm:{thread_id}")
        return raw if raw else None

    async def set_working_memory(self, thread_id: str, data: dict) -> None:
        pipe = self.r.pipeline()
        pipe.hset(f"mem:wm:{thread_id}", mapping=data)
        pipe.expire(f"mem:wm:{thread_id}", WM_TTL)
        await pipe.execute()

    async def update_working_memory(self, thread_id: str, updates: dict) -> None:
        pipe = self.r.pipeline()
        pipe.hset(f"mem:wm:{thread_id}", mapping=updates)
        pipe.expire(f"mem:wm:{thread_id}", WM_TTL)  # renova TTL
        await pipe.execute()

DynamoDB Repository

python — memory/dynamo_repository.py
import boto3
from datetime import datetime, timezone, timedelta
from typing import Optional
from boto3.dynamodb.conditions import Key

def _ttl_epoch(days: int = 90) -> int:
    return int((datetime.now(timezone.utc) + timedelta(days=days)).timestamp())

class DynamoRepository:
    def __init__(self, region: str = "us-east-1"):
        ddb = boto3.resource("dynamodb", region_name=region)
        self.history  = ddb.Table("ConversationHistory")
        self.prefs    = ddb.Table("UserPreferences")
        self.sessions = ddb.Table("SessionIndex")

    # ── ConversationHistory ────────────────────────────────────────────
    async def save_message(
        self, user_id: str, session_id: str, thread_id: str,
        message_id: str, role: str, content: str,
        agent_name: Optional[str] = None, token_count: int = 0,
    ) -> None:
        ts = datetime.now(timezone.utc).isoformat()
        self.history.put_item(Item={
            "PK": f"USER#{user_id}",
            "SK": f"SESSION#{session_id}#MSG#{ts}",
            "message_id": message_id, "thread_id": thread_id,
            "role": role, "content": content,
            "agent_name": agent_name or "supervisor",
            "token_count": token_count, "ttl": _ttl_epoch(),
        })

    async def save_session_summary(
        self, user_id: str, session_id: str, summary: str,
        topics: list, sentiment: str, resolved: bool,
        turn_count: int, started_at: str, ended_at: str,
    ) -> None:
        self.history.put_item(Item={
            "PK": f"USER#{user_id}", "SK": f"SUMMARY#{session_id}",
            "session_id": session_id, "summary": summary,
            "topics": topics, "sentiment": sentiment,
            "resolved": resolved, "turn_count": turn_count,
            "started_at": started_at, "ended_at": ended_at,
            "ttl": _ttl_epoch(),
        })

    async def get_session_messages(self, user_id: str, session_id: str) -> list:
        resp = self.history.query(
            KeyConditionExpression=
                Key("PK").eq(f"USER#{user_id}") &
                Key("SK").begins_with(f"SESSION#{session_id}#MSG#"),
        )
        return sorted(resp["Items"], key=lambda x: x["SK"])

    async def get_recent_summaries(self, user_id: str, limit: int = 5) -> list:
        resp = self.history.query(
            IndexName="SessionSummaryIndex",
            KeyConditionExpression=
                Key("PK").eq(f"USER#{user_id}") &
                Key("SK").begins_with("SUMMARY#"),
            ScanIndexForward=False,
            Limit=limit,
        )
        return resp["Items"]

    # ── UserPreferences ────────────────────────────────────────────────
    async def get_preferences(self, user_id: str) -> Optional[dict]:
        resp = self.prefs.get_item(
            Key={"PK": f"USER#{user_id}", "SK": "PREF#PROFILE"}
        )
        return resp.get("Item")

    async def save_preferences(self, user_id: str, prefs: dict) -> None:
        now = datetime.now(timezone.utc).isoformat()
        self.prefs.put_item(Item={
            "PK": f"USER#{user_id}", "SK": "PREF#PROFILE",
            **prefs, "last_updated": now,
        })

    async def save_preference_fact(
        self, user_id: str, fact_key: str, fact_value: str,
        confidence: float, source_session: str,
    ) -> None:
        self.prefs.put_item(Item={
            "PK": f"USER#{user_id}", "SK": f"PREF#FACT#{fact_key}",
            "fact_key": fact_key, "fact_value": fact_value,
            "confidence": str(confidence),
            "inferred_at": datetime.now(timezone.utc).isoformat(),
            "source_session": source_session,
        })

    # ── SessionIndex ───────────────────────────────────────────────────
    async def register_session(
        self, user_id: str, session_id: str, status: str = "active"
    ) -> None:
        now = datetime.now(timezone.utc).isoformat()
        self.sessions.put_item(Item={
            "PK": f"USER#{user_id}",
            "SK": f"SESSION#{now}#{session_id}",
            "session_id": session_id, "status": status,
            "thread_count": 0, "ttl": _ttl_epoch(),
        })

Context Enricher — Read-through com fallback

python — supervisor/context_enricher.py
import json
from typing import TypedDict, Optional
from memory.session_store     import SessionStore
from memory.memory_store      import MemoryStore
from memory.dynamo_repository import DynamoRepository

class EnrichedContext(TypedDict):
    user_id:          str
    session_id:       str
    thread_id:        str
    preferences:      dict
    ltm_summaries:    list[dict]
    working_memory:   dict
    session_metadata: dict

class ContextEnricher:
    def __init__(self, sessions: SessionStore, memory: MemoryStore, dynamo: DynamoRepository):
        self.sessions = sessions
        self.memory   = memory
        self.dynamo   = dynamo

    async def enrich(self, session_id: str, thread_id: str, user_id: str) -> EnrichedContext:
        # 1. Session metadata
        session = await self.sessions.get_session(session_id) or {}

        # 2. UserPreferences — Redis primeiro, fallback DynamoDB (warm-up automático)
        preferences = await self.memory.get_preferences(user_id)
        if not preferences:
            preferences = await self.dynamo.get_preferences(user_id) or {}
            if preferences:
                await self.memory.set_preferences(user_id, preferences)

        # 3. LTM Summaries — Redis primeiro, fallback DynamoDB
        ltm_summaries = await self.memory.get_ltm_summaries(user_id, n=5)
        if not ltm_summaries:
            ltm_summaries = await self.dynamo.get_recent_summaries(user_id, limit=5)

        # 4. Working memory da thread ativa
        working_memory = await self.memory.get_working_memory(thread_id) or {}

        return EnrichedContext(
            user_id=user_id, session_id=session_id, thread_id=thread_id,
            preferences=preferences, ltm_summaries=ltm_summaries,
            working_memory=working_memory, session_metadata=session,
        )

    def build_context_prompt(self, ctx: EnrichedContext) -> str:
        """Serializa contexto como texto para o system prompt."""
        parts = []
        if ctx["preferences"]:
            parts.append(
                f"## User Profile\n{json.dumps(ctx['preferences'], indent=2, ensure_ascii=False)}"
            )
        if ctx["ltm_summaries"]:
            lines = "\n".join(
                f"- [{s.get('created_at','')[:10]}] {s.get('summary','')}"
                for s in ctx["ltm_summaries"]
            )
            parts.append(f"## Recent Session History\n{lines}")
        if ctx["working_memory"]:
            parts.append(
                f"## Current Working Memory\n{json.dumps(ctx['working_memory'], indent=2, ensure_ascii=False)}"
            )
        return "\n\n".join(parts)

LangGraph StateGraph — Supervisor com Memória

python — supervisor/graph.py
from typing import TypedDict, Annotated, Optional
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from memory.langgraph_checkpointer import RedisCheckpointer
from supervisor.context_enricher   import ContextEnricher
from supervisor.thread_archiver    import ThreadArchiver

class AgentState(TypedDict):
    messages:         Annotated[list[BaseMessage], add_messages]
    session_id:       str
    thread_id:        str
    user_id:          str
    enriched_context: dict
    agent_route:      Optional[str]
    resolved:         bool

# ── Nó: enriquecimento ───────────────────────────────────────────────
async def enrich_context_node(state: AgentState, enricher: ContextEnricher) -> AgentState:
    ctx = await enricher.enrich(
        session_id=state["session_id"],
        thread_id=state["thread_id"],
        user_id=state["user_id"],
    )
    context_prompt = enricher.build_context_prompt(ctx)
    system_msg = SystemMessage(content=f"""Você é o Supervisor de um sistema multi-agente.

{context_prompt}

Roteie a solicitação para o agente correto.
Agentes: billing_agent, technical_agent, onboarding_agent.""")
    return {
        **state,
        "enriched_context": dict(ctx),
        "messages": [system_msg] + [m for m in state["messages"]
                                    if not isinstance(m, SystemMessage)],
    }

# ── Nó: supervisor LLM ───────────────────────────────────────────────
async def supervisor_node(state: AgentState) -> AgentState:
    llm = ChatAnthropic(model="claude-sonnet-4-20250514")
    response = await llm.ainvoke(state["messages"])
    return {**state, "messages": [response]}

# ── Nó: roteamento MCP ───────────────────────────────────────────────
async def route_to_agent_node(state: AgentState) -> AgentState:
    mcp_payload = {
        "thread_id": state["thread_id"],
        "user_id":   state["user_id"],
        "context":   state["enriched_context"],
        "messages":  [m.dict() for m in state["messages"][-10:]],
    }
    # agent_response = await mcp_client.call(state["agent_route"], mcp_payload)
    return state

# ── Nó: verificar encerramento ────────────────────────────────────────
async def check_resolution_node(state: AgentState, archiver: ThreadArchiver) -> AgentState:
    last = state["messages"][-1] if state["messages"] else None
    resolved = False
    if last and isinstance(last, AIMessage):
        signals = ["posso ajudar com mais algo", "há algo mais", "problema resolvido"]
        resolved = any(s in last.content.lower() for s in signals)
    if resolved:
        await archiver.archive_thread(
            thread_id=state["thread_id"],
            session_id=state["session_id"],
            user_id=state["user_id"],
            messages=state["messages"],
        )
    return {**state, "resolved": resolved}

def should_end(state: AgentState) -> str:
    return END if state.get("resolved") else "supervisor"

def build_supervisor_graph(
    enricher: ContextEnricher,
    archiver: ThreadArchiver,
    checkpointer: RedisCheckpointer,
) -> StateGraph:
    graph = StateGraph(AgentState)
    graph.add_node("enrich_context",    lambda s: enrich_context_node(s, enricher))
    graph.add_node("supervisor",        supervisor_node)
    graph.add_node("route_agent",       route_to_agent_node)
    graph.add_node("check_resolution",  lambda s: check_resolution_node(s, archiver))
    graph.set_entry_point("enrich_context")
    graph.add_edge("enrich_context",    "supervisor")
    graph.add_edge("supervisor",        "route_agent")
    graph.add_edge("route_agent",       "check_resolution")
    graph.add_conditional_edges("check_resolution", should_end)
    return graph.compile(checkpointer=checkpointer)

Thread Archiver — Encerramento com Summary LLM

python — supervisor/thread_archiver.py
import json
from datetime import datetime, timezone
from langchain_core.messages import BaseMessage
from langchain_anthropic import ChatAnthropic
from memory.memory_store      import MemoryStore
from memory.dynamo_repository import DynamoRepository

class ThreadArchiver:
    def __init__(self, memory: MemoryStore, dynamo: DynamoRepository):
        self.memory = memory
        self.dynamo = dynamo
        self.llm    = ChatAnthropic(model="claude-haiku-4-5-20251001")

    async def archive_thread(
        self, thread_id: str, session_id: str, user_id: str, messages: list[BaseMessage]
    ) -> None:
        ended_at = datetime.now(timezone.utc).isoformat()

        # 1. Gerar summary via LLM
        conv = "\n".join(
            f"[{m.__class__.__name__}]: {m.content}"
            for m in messages if hasattr(m, "content")
        )
        resp = await self.llm.ainvoke([{"role": "user", "content": f"""Analise esta conversa e retorne um JSON com:
- summary: resumo em 1-2 frases do que foi discutido e resolvido
- topics: lista de tópicos principais (array de strings)
- sentiment: "positive" | "neutral" | "negative"
- resolved: true | false
- inferred_preferences: objeto com preferências inferidas (ex: language, response_style)
- facts: objeto com fatos estruturados (ex: plan, segment, last_issue)

Conversa:
{conv}

Retorne SOMENTE o JSON, sem texto adicional."""}])

        try:
            a = json.loads(resp.content)
        except json.JSONDecodeError:
            a = {"summary": "Conversa encerrada.", "topics": [],
                 "sentiment": "neutral", "resolved": True,
                 "inferred_preferences": {}, "facts": {}}

        # 2. Salvar no DynamoDB
        await self.dynamo.save_session_summary(
            user_id=user_id, session_id=session_id,
            summary=a["summary"], topics=a.get("topics", []),
            sentiment=a.get("sentiment", "neutral"), resolved=a.get("resolved", True),
            turn_count=len(messages),
            started_at=ended_at, ended_at=ended_at,
        )

        # 3. Atualizar LTM no Redis
        await self.memory.push_ltm_summary(user_id, {
            "session_id": session_id, "summary": a["summary"],
            "topics": a.get("topics", []), "sentiment": a.get("sentiment", "neutral"),
            "resolved": a.get("resolved", True), "created_at": ended_at,
        })

        # 4. Write-through UserPreferences (DynamoDB → Redis)
        if a.get("inferred_preferences"):
            existing = await self.dynamo.get_preferences(user_id) or {}
            merged   = {**existing, **a["inferred_preferences"]}
            merged["inferred_count"] = str(int(existing.get("inferred_count", 0)) + 1)
            await self.dynamo.save_preferences(user_id, merged)
            await self.memory.set_preferences(user_id, merged)

        # 5. Fatos estruturados
        for key, val in a.get("facts", {}).items():
            await self.dynamo.save_preference_fact(
                user_id=user_id, fact_key=key, fact_value=str(val),
                confidence=0.85, source_session=session_id,
            )

Entrypoint — Uso completo

python — main.py
import asyncio, uuid
from infrastructure.redis_clients  import RedisClients
from memory.session_store           import SessionStore
from memory.memory_store            import MemoryStore
from memory.dynamo_repository       import DynamoRepository
from memory.langgraph_checkpointer  import RedisCheckpointer
from supervisor.context_enricher    import ContextEnricher
from supervisor.thread_archiver     import ThreadArchiver
from supervisor.graph               import build_supervisor_graph
from langchain_core.messages        import HumanMessage

REDIS_CONFIG = {
    "sessions": {"host": "redis-sessions.xxx.cache.amazonaws.com", "port": 6379},
    "threads":  {"host": "redis-threads.xxx.cache.amazonaws.com",  "port": 6379},
    "memory":   {"host": "redis-memory.xxx.cache.amazonaws.com",   "port": 6379},
}

async def handle_message(user_id: str, message: str, session_id: str = None):
    redis        = RedisClients(REDIS_CONFIG)
    sessions     = SessionStore(redis)
    memory       = MemoryStore(redis)
    dynamo       = DynamoRepository()
    checkpointer = RedisCheckpointer(redis)
    enricher     = ContextEnricher(sessions, memory, dynamo)
    archiver     = ThreadArchiver(memory, dynamo)
    graph        = build_supervisor_graph(enricher, archiver, checkpointer)

    if not session_id:
        session_id = f"sess_{uuid.uuid4().hex[:12]}"
        await sessions.create_session(session_id, user_id, {"channel": "api"})
        await dynamo.register_session(user_id, session_id)
    else:
        await sessions.touch_session(session_id)

    thread_id = f"thrd_{uuid.uuid4().hex[:12]}"  # nova thread por conversa

    config = {"configurable": {"thread_id": thread_id}}
    state  = {
        "messages":         [HumanMessage(content=message)],
        "session_id":       session_id,
        "thread_id":        thread_id,
        "user_id":          user_id,
        "enriched_context": {},
        "agent_route":      None,
        "resolved":         False,
    }
    result = await graph.ainvoke(state, config=config)
    await redis.close()
    return result

if __name__ == "__main__":
    asyncio.run(handle_message(
        user_id="usr_xyz789",
        message="Preciso entender minha fatura do mês passado",
    ))

Fluxo Ponta a Ponta — Lifecycle Completo

1
Nova mensagem do usuário
SessionStore.get_session() + touch_session() → redis-sessions (renova sliding TTL)
2
ContextEnricher carrega todos os layers
get_preferences() → redis-memory → fallback DynamoDB (warm-up)
get_ltm_summaries() → redis-memory → fallback DynamoDB
get_working_memory() → redis-memory
3
LangGraph.ainvoke() — execução do grafo
enrich_context_node injeta SystemMessage com contexto completo → supervisor_node LLM decide rota → route_agent_node envia MCP payload enriquecido para Agents Account
4
check_resolution_node — loop ou encerramento
LLM sinalizou encerramento explícito ou inatividade de 30min detectada → dispara ThreadArchiver.archive_thread()
5
ThreadArchiver — arquivamento e geração de LTM
LLM (Haiku) gera summary estruturado → DynamoDB.save_session_summary() → MemoryStore.push_ltm_summary() → write-through UserPreferences (DynamoDB primeiro, Redis depois)
6
TTL Redis expira naturalmente
Dados já persistidos no DynamoDB. Redis libera memória automaticamente.
7
DynamoDB Streams → Lambda → S3 Data Lake
Após 90 dias, TTL do DynamoDB expira. Streams dispara Lambda que serializa em Parquet no S3.

Resumo de TTLs e Decisões

Layer Cluster / Store TTL Primário Invalidação
Session store redis-sessions 2h sliding Redis Expiração / close explícito
Thread state + checkpointer redis-threads 30min sliding Redis Arquivamento antes da expiração
Working memory redis-memory 30min Redis Expira com a thread
UserPreferences cache redis-memory sem TTL DynamoDB Write-through por evento
LTM Summaries cache redis-memory sem TTL DynamoDB LPUSH + LTRIM (top 10)
ConversationHistory DynamoDB 90 dias DynamoDB DynamoDB Streams → S3
UserPreferences DynamoDB permanente DynamoDB Merge por inferência
SessionIndex DynamoDB 90 dias DynamoDB —
Data Lake S3 Parquet permanente S3 —
Ponto de atenção — SQS como safety net: o ThreadArchiver deve ser envolvido em uma fila SQS com retry antes do fluxo síncrono em produção. Se o archiver falhar silenciosamente e o Redis expirar, o estado da thread é perdido. Uma fila garante pelo menos 3 tentativas com backoff exponencial.
Redis ElastiCache DynamoDB LangGraph Checkpointer Short-term Memory Long-term Memory UserPreferences Session Store Write-through TTL S3 Data Lake DynamoDB Streams