TL;DR

Segundo artigo da série sobre construir o Bloom, um agente de coaching multiagente, em uma semana. Se o primeiro artigo tratava de quem decide para onde a conversa vai, este trata do que muda quando o agente para de só conversar e passa a agir de verdade no mundo — no caso, escrevendo eventos reais no Google Calendar via MCP.

  • Leitura separada de escrita: as tools do Planning Specialist se dividem em leitura (checar disponibilidade, listar compromissos) e escrita (propor horários, confirmar o plano). A escrita só dispara com concordância explícita do usuário registrada na conversa — nunca porque o modelo “achou” que já tinha informação suficiente.
  • Todo loop precisa de teto: o loop ReAct do Planning Specialist é limitado a 5 iterações por turno, ecoando o limite de 3 delegações por turno do Coordinator no artigo anterior. Um loop sem teto é primeiro um risco de latência e custo, antes mesmo de ser um risco de correção — o limite precisa nascer no design, não como patch depois da reclamação.
  • Sinal de controle não deve viajar escondido dentro do texto: forçar o modelo a responder sempre em JSON estruturado ({"response": "...", "confirmed": true}) engessava mensuravelmente o tom da conversa. Mover o confirmed para uma tool call própria e deixar o texto livre resolveu — sempre que uma resposta carrega mensagem para o usuário e sinal de máquina ao mesmo tempo, os dois devem ir em canais diferentes.
  • Latência muda de natureza com loops: uma confirmação de plano passa a custar 2 ou 3 chamadas de LLM em sequência, o que quebra qualquer SLA pensado para chamada única. Streaming resolve a percepção (“pensando” em vez de “travado”) com pouco código, e telemetria por turno deixa de fazer sentido sem granularidade por chamada.
  • Escopo mínimo e integrações sandboxadas: a integração OAuth pede só calendar.events, nunca mais que isso mesmo quando o SDK facilitaria pedir mais. O Google Calendar fica atrás de um servidor MCP dedicado, o que torna a integração trocável e contém o raio de explosão de qualquer falha ou credencial comprometida.
  • Resiliência sem quebrar a conversa: conexão dual-mode (SSE com fallback para HTTP POST, e depois para um mock store local) garante que uma integração externa instável vire, na pior hipótese, um detalhe técnico — não uma conversa quebrada para quem está do outro lado.
  • Minimizar dado antes de proteger dado: limitar o histórico enviado ao LLM às últimas 15 mensagens (com resumo local do resto) é ao mesmo tempo controle de custo e controle de privacidade — menos dado bruto cruzando a rede a cada chamada.

Decidir com antecedência quem tem autoridade para agir e sob quais condições exatas essa autoridade se ativa — a mesma régua código-vs-modelo do primeiro artigo, agora aplicada a ação real no mundo, não só a roteamento.


Fazendo o loop agir sem sair do controle

Artigo 2 de uma série sobre o que aprendi construindo um agente de coaching multiagente em uma semana


No artigo anterior, fechei com uma pergunta: em cada decisão do sistema, quem manda — o código ou o modelo? Falei de roteamento, de quem decide para qual especialista uma conversa vai. Mas roteamento é só metade da história. A outra metade aparece assim que um agente para de apenas conversar e começa a agir — escrever numa agenda, confirmar um compromisso, mudar algo que existe fora da conversa.

É aqui que o Planning Specialist do Bloom entra em cena. Ele não é um chatbot que sugere horários; ele tem quatro tools reais de calendário, conectadas via MCP (Model Context Protocol), e pode, de fato, criar eventos no Google Calendar de alguém. A pergunta deste artigo é uma variação direta da anterior: como dar a um agente liberdade real para agir no mundo sem abrir mão do controle sobre quando e como ele age?


Separe tools de leitura de tools de escrita

A primeira decisão de design foi dividir as quatro tools do Planning Specialist em dois grupos de naturezas completamente diferentes:

  • Leitura: get_free_busy (disponibilidade nos próximos 7 dias, em blocos de 30 minutos) e list_upcoming (sessões já agendadas, para evitar conflito de horário)

  • Escrita: propose_sessions (compromisso com horários específicos, ainda sem gravar nada) e confirm_plan (grava de fato na agenda)

A ferramenta de escrita só é acionada após um gatilho explícito — a concordância real do usuário, registrada na transcrição da conversa. Não depende do modelo “achar” que já reuniu informação suficiente para agir. Essa é uma das regras mais baratas e mais gerais que carrego deste projeto: qualquer agente que escreve numa agenda, envia um e-mail ou gasta dinheiro real precisa dessa separação entre olhar e agir, com um gatilho explícito de concordância entre as duas etapas.

Aqui está como essas ferramentas são estruturadas no nosso planning.agent.ts para separar as operações de leitura das ações de escrita:

const PLANNING_TOOLS: ToolDefinition[] = [
  // --- FERRAMENTAS DE LEITURA (Sem efeitos colaterais) ---
  {
    name: 'get_free_busy',
    description: 'Get calendar availability for the next 7 days in 30-minute slots. Use this before proposing sessions.',
    parameters: { type: 'object', properties: { week_start: { type: 'string' } } }
  },
  {
    name: 'list_upcoming',
    description: 'List already-scheduled learning sessions to avoid double-booking.',
    parameters: { type: 'object', properties: { limit: { type: 'number' } } }
  },
  // --- FERRAMENTAS DE ESCRITA (Exigem confirmação do usuário) ---
  {
    name: 'propose_sessions',
    description: 'Commit to specific session times and present them to the learner. No calendar write yet.',
    parameters: {
      type: 'object',
      properties: {
        sessions: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              title: { type: 'string' },
              scheduled_at: { type: 'string' },
              duration_minutes: { type: 'number' }
            },
            required: ['title', 'scheduled_at', 'duration_minutes']
          }
        }
      },
      required: ['sessions']
    }
  },
  {
    name: 'confirm_plan',
    description: 'Write confirmed sessions to the calendar. Only call this after the learner has explicitly agreed.',
    parameters: {
      type: 'object',
      properties: {
        weekly_goal: { type: 'string' },
        sessions: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              title: { type: 'string' },
              scheduled_at: { type: 'string' },
              duration_minutes: { type: 'number' }
            },
            required: ['title', 'scheduled_at', 'duration_minutes']
          }
        }
      },
      required: ['weekly_goal', 'sessions']
    }
  }
];

Todo loop precisa de um teto

O Planning Specialist não chama as quatro tools de qualquer jeito — ele roda um loop ReAct (raciocinar, agir, observar, repetir) limitado a 5 iterações por turno. Isso ecoa a decisão do Coordinator no artigo anterior, que limita delegações a 3 por turno.

Um loop sem teto é, antes de mais nada, um risco de latência e custo — muito antes de ser um risco de correção. Um agente pode ficar preso em ciclos de “deixa eu checar de novo”, e o usuário paga essa indecisão em segundos de espera e em tokens gastos. A regra prática que fica: todo loop agentico ganha um número máximo de iterações desde o design, não como um patch aplicado depois que alguém reclamar da latência.

No planning.agent.ts, o loop ReAct é limitado a no máximo 5 iterações para evitar ciclos infinitos:

// Dentro de PlanningAgent.processTurn:
for (let i = 0; i < 5; i++) {
  const result = await llmService.generateWithTools(systemPrompt, messages, PLANNING_TOOLS);

  // Texto indica que o agente concluiu seu raciocínio e respondeu ao usuário
  if (result.type === 'text') {
    return { response: result.content, confirmed: false };
  }

  // Operação de escrita encerra o loop e grava o plano
  if (result.name === 'confirm_plan') {
    const rawSessions = result.args.sessions as RawSession[];
    const weeklyGoal = (result.args.weekly_goal as string) || `Complete study for ${goal}`;

    const sessionsWithIds = await Promise.all(
      rawSessions.map(async s => {
        const scheduledAt = new Date(s.scheduled_at);
        const { eventId, synced } = await calendarService.createEvent(s.title, scheduledAt, s.duration_minutes);
        return {
          topic: s.title,
          duration_minutes: s.duration_minutes,
          scheduled_at: scheduledAt,
          format: 'practice' as const,
          effort_level: 'moderate' as const,
          calendar_event_id: eventId,
          synced
        };
      })
    );

    return {
      response: `Your plan is confirmed — ${sessionsWithIds.length} sessions added to your calendar.`,
      confirmed: true,
      proposedPlan: { weekly_goal: weeklyGoal, sessions: sessionsWithIds }
    };
  }

  // Executa as ferramentas auxiliares de leitura (get_free_busy, list_upcoming, propose_sessions)
  const toolResult = await this.executeTool(result.name, result.args, goal);

  // Insere a chamada de tool e a observação de volta no contexto
  messages.push({
    role: 'assistant',
    content: '',
    toolCall: { id: result.id, name: result.name, args: result.args }
  });
  messages.push({
    role: 'tool',
    content: typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult),
    toolCallId: result.id,
    toolName: result.name
  });
}

Sinal de controle não deve viajar escondido dentro do texto

Uma versão inicial do Bloom forçava o modelo a responder sempre em um JSON estruturado: {"response": "...", "confirmed": true}. A ideia parecia razoável — um único formato de saída que carrega tanto a mensagem para o usuário quanto um sinal de controle interno (o plano foi confirmado ou não).

O problema apareceu no tom. Forçar o modelo a produzir esse JSON a cada turno, de forma mensurável, engessava a conversa — o texto ficava mais seco, mais burocrático, menos natural. (Vou voltar a esse ponto específico com mais profundidade no próximo artigo, porque ele é, na verdade, uma lição sobre prompt, não só sobre tools.)

A solução foi mover o confirmed para uma tool call própria — confirm_plan — e deixar o canal de texto livre para ser apenas conversa.

O aprendizado que extraí desta etapa foi o seguinte: sempre que uma resposta precisa carregar, ao mesmo tempo, uma mensagem para o usuário e um sinal legível por máquina, separe os dois em canais diferentes. Uma tool call para o sinal, texto livre para a fala. Misturar estrutura dentro da fala tem um custo de tom que é fácil de subestimar até que você meça.

Latência muda de natureza quando você adiciona loops

Um SLA de latência pensado para um sistema de chamada única simplesmente morre no momento em que uma confirmação de plano passa a custar 2 ou 3 chamadas de LLM em sequência. Isso não é um detalhe de infraestrutura — é uma consequência direta e previsível de introduzir um loop de ferramentas.

A saída prática foi streaming: fazer com que a latência de um processo de várias etapas seja percebida como “o assistente está pensando”, em vez de “o assistente travou”. Com pouco código, resolve um problema que, sem ele, parece muito mais grave do que é.

Isso também empurra uma consequência para a camada de observabilidade: assim que o número de chamadas por turno vira variável — porque depende de quantas iterações do loop foram necessárias — uma métrica única de “latência por turno” deixa de fazer sentido sem granularidade por chamada. Vou voltar a isso com mais detalhe no último artigo da série, quando falar de telemetria.

Escopo mínimo de permissão, mesmo quando o SDK facilita pedir mais

A integração OAuth do Bloom com o Google Calendar é restrita estritamente a calendar.events — nunca contatos, e-mail ou drive. O SDK tornaria pedir um escopo mais amplo igualmente fácil; a disciplina de pedir só o mínimo necessário é uma escolha deliberada, não uma limitação imposta pela ferramenta.

Essa é uma daquelas regras que parecem óbvias quando enunciadas assim, e que são sistematicamente ignoradas na prática, porque o caminho de menor resistência ao integrar um provedor externo é sempre pedir o escopo mais genérico disponível ‘por segurança’ — o que, na prática, é o oposto de segurança.

Sandbox de integrações externas atrás de um protocolo

Em vez de embutir o SDK do Google Calendar e suas credenciais diretamente na aplicação, o Bloom sandboxa essa integração atrás de um servidor MCP dedicado. Isso traz dois benefícios distintos:

  1. A integração fica trocável — Google Calendar hoje, qualquer servidor CalDAV amanhã, sem reescrever a aplicação inteira.

  2. Uma integração comprometida ou com bug não tem acesso direto ao resto das credenciais da aplicação. O raio de explosão de uma falha fica contido no próprio servidor MCP.

Podemos visualizar essa separação de sandbox com o seguinte diagrama:

graph LR
    classDef app fill:#4f46e5,stroke:#312e81,stroke-width:2px,color:#ffffff;
    classDef mcp fill:#0ea5e9,stroke:#0369a1,stroke-width:1px,color:#ffffff;
    classDef api fill:#10b981,stroke:#047857,stroke-width:1px,color:#ffffff;

    Backend(Serviço Backend Bloom):::app <--> |SSE / protocolo JSON-RPC| CalendarMCP(Servidor Google Calendar MCP):::mcp
    CalendarMCP <--> |HTTPS OAuth / escopo: calendar.events| GoogleAPI(Google Calendar API):::api

Resiliência a falhas de integração sem quebrar a conversa

Integrações externas caem. A pergunta de design não é “como evitar isso” — é “o que acontece com a conversa quando isso acontece”. O Bloom usa uma conexão dual-mode: SSE como primeira opção, com fallback para HTTP POST depois de um timeout de 2 segundos. Se o servidor de calendário estiver completamente offline, existe ainda um fallback para um mock store local.

O objetivo prático é que a conversa continue fluida mesmo com a integração instável. Um erro técnico de rede não precisa virar um erro de experiência para quem está do outro lado, tentando marcar uma sessão de estudo.

Aqui está como o CalendarService implementa essa resiliência no calendar.service.ts usando Promise.race para o limite de tempo do handshake e canais de fallback:

// Conecta ao servidor MCP com um limite estrito de 2 segundos
private async getMcpClient(): Promise<any | null> {
  // ...
  try {
    const transport = new SSEClientTransport(new URL(sseUrl));
    const client = new Client({ name: 'bloom-coaching-backend', version: '1.0.0' }, { capabilities: {} });

    const connectPromise = client.connect(transport);
    const timeoutPromise = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('MCP handshake timeout after 2000ms')), 2000)
    );

    await Promise.race([connectPromise, timeoutPromise]);
    this.client = client;
    return this.client;
  } catch (err) {
    console.warn(`[CalendarService] Failed to initialize MCP client, falling back to mock:`, err);
    this.client = null;
    return null;
  }
}

// Tenta o MCP, falha para HTTP POST direto, e depois para o mock store local
public async createEvent(title: string, start: Date, durationMinutes: number) {
  let googleEventId: string | null = null;
  let synced = false;

  if (sseUrl) {
    const client = await this.getMcpClient();
    if (client) {
      try {
        const toolResult = await client.callTool({
          name: 'create_calendar_event',
          arguments: { summary: title, start: start.toISOString(), end: end.toISOString() }
        });
        googleEventId = JSON.parse(toolResult.content[0].text)?.event?.id ?? null;
        synced = true;
      } catch (err) {
        console.warn(`[MCP Calendar] Failed to call external calendar tool, trying POST fallback:`, err);
      }
    }

    // Fallback para HTTP POST direto se a conexão com o MCP falhar
    if (!synced) {
      try {
        const res = await fetch(sseUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            name: 'create_event',
            parameters: { summary: title, start: start.toISOString(), end: end.toISOString() }
          })
        });
        if (res.ok) {
          const data = await res.json();
          googleEventId = data?.value?.event?.id ?? null;
          synced = true;
        }
      } catch (postErr) {
        console.warn('[MCP Calendar] Direct HTTP POST fallback failed:', postErr);
      }
    }
  }

  // Sempre grava na memória mock local para nunca interromper a conversa do usuário
  const eventId = googleEventId ?? crypto.randomUUID();
  CalendarService.mockEvents.set(eventId, { id: eventId, title, start, end });
  return { eventId, synced };
}

Minimizar dado cruzando a rede, não só proteger o que já cruzou

Por fim, um controle que serve a dois propósitos ao mesmo tempo: o histórico de conversa enviado à API do LLM é limitado às últimas 15 mensagens. Histórico mais antigo é comprimido localmente em resumos e nunca reenviado em bloco para o modelo.

É simultaneamente um controle de custo (menos tokens por chamada) e um controle de privacidade — menos dados brutos do usuário atravessam a rede a cada chamada. É um bom exemplo de uma decisão que parece ser apenas sobre performance até que você perceba que ela também é, silenciosamente, sobre proteção de dados.


O fio que se repete

Separar leitura de escrita, limitar loops, separar canais de sinal, escopar permissões ao mínimo, sandboxar integrações, minimizar os dados que trafegam — todas essas são variações concretas do mesmo princípio do primeiro artigo desta série: decidir com antecedência quem tem autoridade para agir e sob quais condições exatas essa autoridade se ativa.

Ficou pendente, no meio do caminho, uma observação que eu deliberadamente não aprofundei: o JSON de controle embutido no texto não foi só um problema de arquitetura — ele mudou o tom do modelo, de forma mensurável. Essa observação puxa diretamente para o próximo artigo da série, onde a camada de prompt — e não a de tools — vira o assunto central.