Pular para o conteúdo

Loop de agente (chat completions)

Quando uma organização tem pelo menos um servidor MCP outbound habilitado (configurado em MCP → Servers), toda requisição em /v1/chat/completions passa automaticamente pelo loop de agente. A decisão é server-side, baseada na flag mcp_agent_active do organization_profiles:

mcp_agent_active = has_mcp_outbound AND EXISTS(SELECT 1 FROM mcp_outbound_servers s WHERE s.org_id = ... AND s.enabled = true)

Desabilite todos os servidores (ou remova has_mcp_outbound do plano) e a próxima requisição volta para o caminho pass-through padrão sem nenhuma mudança de comportamento.

Um cliente cuja org tem o loop de agente ativo ainda pode rodar uma chat completion pass-through pura mandando o header floopy-mcp-disabled:

floopy-mcp-disabled: true

Valores truthy (true / 1 / yes, case-insensitive) pulam o caminho do agente para aquela única requisição. Útil quando o caller sabe que o prompt não precisa de tools e quer evitar o overhead por round do agente.


client → /v1/chat/completions
├─ org_ctx.mcp_agent_active = true E sem header de opt-out
agent::agent_loop::run
│ a cada round:
│ 1. budget.check_round (max_rounds, wall-clock)
│ 2. chamada LLM via execute_strategies ← span por round
│ • cancelável via tokio::select!
│ • dispatcha para provider (OpenAI/Anthropic/Gemini/Bedrock)
│ 3. sem tool_calls → retorna mensagem final do assistente
│ 4. com tool_calls → dispatch_round (paralelo, allowlist-checado)
│ 4a. cache hit? reusa o valor
│ 4b. miss? resolve secret_ref, chama upstream, cacheia
│ 4c. SSRF guard checa o IP resolvido — bloqueio aborta a
│ request inteira, não só a tool call (ver Segurança)
│ 5. emite audit_log.agent.tool_call por dispatch
│ 6. anexa mensagens de tool, loop

Descoberta de tools (auto-discovery, não declarada)

Seção intitulada “Descoberta de tools (auto-discovery, não declarada)”

A Floopy segue o modelo padrão de mercado para MCP: clientes referenciam servidores, não tools individuais. O loop de agente chama tools/list em cada servidor habilitado em paralelo (cache TTL de 10 minutos), agrega os resultados e expõe a união ao LLM. Tools são deduplicadas por nome — em colisões, vence o primeiro servidor (ordem de configuração).

Validação estrita de input-schema: tools cujo inputSchema esteja ausente, null ou não seja um objeto JSON são removidas do catálogo. A remoção é logada como mcp_tool_invalid_schema; se um servidor ficar sem tools válidas após validação, dispara um warning de nível de servidor mcp_server_no_valid_tools. A spec MCP exige inputSchema, então um servidor caindo aqui em produção está mal configurado.


Três tetos independentes, aplicados dentro do loop:

  • Rounds — limitado em runtime.max_rounds (1..50, default 10), e adicionalmente em MAX_ROUNDS_CAP = 50 independente da config da org.
  • Wall clock — deadline de 120s selado na aquisição. Cada round corre a chamada LLM e o dispatch das tools contra tokio::time::sleep_until(deadline) via tokio::select!, então um upstream lento não consegue ultrapassar o deadline.
  • Concorrência — 16 runs simultâneos por org, aplicado por contador atômico em Redis (Lua GET-and-conditional-INCR). Excedente retorna 429 com retry_after_secs = 60.

Esgotar rounds ou wall-clock emite uma linha audit_log.agent.budget_exhausted e retorna as mensagens parciais com finish_reason = "length".


Tool calls idênticas (mesma chave (org_id, server_id, tool_name, canonical_args)) dentro de runtime.tool_cache_ttl_seconds reusam o resultado anterior. O valor cacheado é HMAC-assinado com FLOOPY_AGENT_CACHE_PEPPER, então:

  • Um writer no Redis não consegue injetar um resultado forjado.
  • Um valor de uma (org, server, tool) não pode ser migrado para outra — a chave do cache faz parte do MAC.
  • Uma rotação de pepper invalida toda entrada cacheada de uma vez.

tool_cache_ttl_seconds = 0 desabilita o cache por completo.


runtime.stream_mode é o switch da org entre dois modos:

  • final_only — quando o cliente pede stream: true, o loop roda até o fim (rounds intermediários bufferizados server-side), e a mensagem final do assistente é fragmentada em múltiplos frames SSE delta.content de ~64 bytes para o cliente ver a resposta progressivamente. Limites UTF-8 são preservados. Texto intermediário (ex.: “Vou consultar o clima.”) não é streamado ao vivo nesta versão — fica reservado para um follow-up que conduza cada round através do upstream em modo stream.
  • disabled — mesmo que o cliente peça stream: true, a resposta é um único chat.completion JSON não-streaming. Útil para integrações que não consomem SSE.

Quando o cliente não pede streaming, ambos os modos retornam JSON.


Tools, tool_use / tool_calls e tool_result fazem round-trip por todos os providers da Floopy:

ProviderCampo de toolCampo de resultadoStop reason
OpenAI / OpenAI-compattools[].function.parameterstool_calls[]tool_calls
Anthropictools[].input_schemacontent[].tool_usetool_use
Geminitools[].functionDeclarationsparts[].functionCall(inferido pelo tipo de part)
Bedrock ConversetoolConfig.tools[].toolSpecoutput.message.content[].toolUsetool_use

A Floopy traduz entre essas formas no fio — clientes sempre falam o shape do OpenAI.


Toda chamada HTTP outbound do loop de agente (e do delivery de webhooks) passa por uma policy SSRF cluster-wide carregada uma vez de variáveis de ambiente no startup. O comportamento default mantém os blocks hardcoded pré-PR-2; operadores podem ajustar sem mudança de código:

FLOOPY_OUTBOUND_SSRF_BLOCK_LOOPBACK=true
FLOOPY_OUTBOUND_SSRF_BLOCK_PRIVATE=true # RFC1918
FLOOPY_OUTBOUND_SSRF_BLOCK_LINK_LOCAL=true # inclui 169.254.169.254 (cloud metadata)
FLOOPY_OUTBOUND_SSRF_BLOCK_MULTICAST=true
FLOOPY_OUTBOUND_SSRF_BLOCK_CGNAT=true # 100.64/10
FLOOPY_OUTBOUND_SSRF_BLOCK_ULA_V6=true # fc00::/7
FLOOPY_OUTBOUND_SSRF_EXTRA_BLOCKED_CIDRS= # CSV de CIDRs v4
FLOOPY_OUTBOUND_SSRF_ALLOWLIST_CIDRS= # CSV; sobrepõe todo block

Um bloqueio durante o loop aborta a request inteira com HTTP 403 (outbound_ssrf_blocked: ssrf_blocked:{reason}:{ip}). Não realimenta o erro ao LLM como tool_result — fazer isso permitiria que um atacante controlando uma URL de servidor MCP enumerasse a rede interna da Floopy uma tool call por vez.


Toda tool call produzida pelo LLM é re-validada contra o inputSchema retornado pelo tools/list do upstream antes do Floopy dispatchar. Uma violação de schema vira tool error realimentado ao LLM em vez de crash duro — o loop continua e o modelo tem chance de retry com argumentos corretos. Bloqueios SSRF (acima) são a única classe fatal.


Cada round escreve no ClickHouse request_response_rmt com:

  • surface = 'agent'
  • agent_run_id — UUID; igual ao request_id do round 0, reusado entre rounds.
  • round_index — zero-based.
  • tool_call_indexNULL na linha da chamada LLM; carregado separadamente em linhas de audit por dispatch.

Cada tool call escreve uma linha audit_log.agent.tool_call com (agent_run_id, round_index, tool_call_index, server_id, tool, status, latency_ms).

O orquestrador abre um span routing_execute em volta da invocação inteira do agente, com N spans filhos agent_round_{idx} (um por dispatch LLM) carregando atributos round_index e agent_run_id. Junte tudo por agent_run_id para rastreabilidade ponta-a-ponta.


  • Por requisição: header floopy-mcp-disabled: true.
  • Por servidor: toggle do Switch no card do Server.
  • Por org: remova has_mcp_outbound do plano ou desabilite todos os servidores.
  • Tweak por runtime: tool_cache_ttl_seconds = 0 desabilita só o cache; max_rounds = 1 força comportamento single-shot.

O overhead do loop quando inativo é uma checagem booleana em OrgContext.mcp_agent_active mais uma leitura opcional de header.