Da solicitação à resposta
As duas decisões estruturais que explicam o resto
O que você vai montar, etapa por etapa →
[PREENCHER] no documento original de propósito.
Panorama
Da solicitação à resposta
As duas decisões estruturais que explicam o resto
O que você vai montar, etapa por etapa →
[PREENCHER] no documento original de propósito.
O desenho separa quatro planos, e a regra é que nenhum plano confia no anterior sem validação. Clique em cada plano para ver o que ele carrega e, mais importante, o que ele recusa receber pronto do plano de cima.
Superfície de rede: o que fica exposto e o que não
Um único ingresso, e ele é o Traefik. O painel do
9Router faz bind em 127.0.0.1 e só é acessado por túnel SSH. O Langfuse fica atrás de
acesso restrito porque contém dado de conversa, o que muita gente esquece ao tratá-lo como
"ferramenta de dev".
platform/
├── agents/ # definições declarativas (yaml), uma por agente
├── prompts/ # prompts versionados, hash registrado no deploy
├── schemas/ # JSON Schema de entrada e de saída de cada agente
├── mcp/
│ ├── hris/ # servidor MCP do HRIS legado
│ ├── docs/ # busca em políticas (RAG)
│ ├── itsm/ # chamados
│ └── comms/ # e-mail e mensageria
├── runtime/ # loop do agente, política de ferramentas, memória
├── services/
│ ├── pii-gateway/ # tokenização e reidratação
│ └── acl/ # camada anticorrupção do RH legado
├── workflows/ # exports do n8n (json), fonte de verdade
├── config/
│ ├── 9router.json # provedores, tiers, limites
│ ├── mcp.json # registro dos servidores no runtime
│ ├── otel-collector.yaml
│ └── traefik/
├── db/migrations/ # DDL versionado
├── mocks/hris/ # HRIS falso: fixtures com vigência, $metadata, token
├── evals/ # golden sets, rubricas, suítes de red team
├── baselines/ # resultados congelados por agente+modelo
├── ci/ # scripts de gate do pipeline
├── docker-compose.yml
├── Makefile
└── .env.example# 1. clonar e preparar segredos
git clone git@git.interno:hr-agents/platform.git && cd platform
cp .env.example .env
make secrets # gera chaves, sobe o Vault em dev e popula os segredos
# 2. subir a plataforma
make up # docker compose up -d --profile core
make status # health check de cada serviço
# 3. servir os modelos locais (GPU)
make serve-local MODEL=Qwen/Qwen2.5-14B-Instruct GPUS=2
# 4. registrar os servidores MCP no runtime
make hris-seed # popula o mock com as fixtures de colaborador
make mcp-register SERVER=hris-mcp
make mcp-list # lista ferramentas expostas e seus schemas
# 5. importar os workflows e ativar
make n8n-import DIR=./workflows
make n8n-activate WF=onboarding-v3
# 6. rodar a bateria de avaliação antes de liberar
make eval SUITE=all
make eval-report # relatório comparativo com o baseline
# operação do dia a dia
make logs SVC=agent-runtime FOLLOW=1
make trace ID=exec_01J8Z… # abre o trace completo no Langfuse
make replay ID=exec_01J8Z… # reexecuta a run contra o código atual
make dlq-list QUEUE=hris-writes # o que falhou e por quê
make dlq-retry ID=msg_8821 # reprocessa após corrigir a causa
make rotate-secret NAME=hris_oauth_key
make kill-switch AGENT=onboarding # desativa o agente sem derrubar o n8nmake replay é o comando mais usado do conjunto. Como todo passo do agente é
persistido (prompt, ferramentas oferecidas, chamada escolhida, resposta bruta da API, decisão
final), qualquer incidente vira um caso reproduzível em segundos e depois vira caso de teste no
repositório de avaliação. Sem trace persistido, esse comando não existe e todo diagnóstico volta a
ser conversa.
Agentes pequenos, com escopo estreito e ferramentas mínimas. Um agente que pode fazer tudo é um agente que não se consegue testar nem auditar. Cinco agentes cobrem o processo inteiro de RH aqui, e nenhum deles é generalista.
Inventário de agentes
Catálogo de ferramentas
Cada ferramenta declara três coisas além do schema: se tem efeito colateral, qual escopo OAuth exige e se precisa de aprovação humana. O runtime recusa qualquer chamada fora dessa declaração, e é isso que tira a decisão de segurança da mão do prompt.
Monte um agente e veja o runtime aceitar ou recusar
Escolha o agente, marque as ferramentas que você daria a ele e mande rodar. A política não está no prompt: está na definição do agente, e o runtime aplica antes da primeira chamada.
A ordem importa: dados e segredos primeiro, depois modelos, depois ferramentas, e o orquestrador por último, assim cada camada sobe com a dependência já respondendo. Monte a sequência abaixo clicando nos serviços. O smoke test roda no fim e aponta o que quebra se você inverter alguma coisa.
O que o smoke test precisa provar
run_idaudit_log encadeado e íntegroO 9Router é um proxy compatível com a API da OpenAI entre o runtime e os provedores. O runtime conhece um único endpoint, e qual modelo atende cada chamada vira configuração, não código. Isso resolve quatro coisas de uma vez: fallback entre provedores, controle de custo por modelo, troca de modelo sem deploy e um ponto único para medir consumo.
O roteador de tier, com o dado real na mão
Escolha o que entra no contexto e veja para onde a chamada vai. O tier sensível não tem fallback de propósito: ligue o interruptor abaixo e veja o que acontece com o dado quando o modelo local cai.
log_prompts desligado no
roteador e fallback: false no tier sensível. Um fallback silencioso do modelo local para
um provedor externo é exatamente a forma como dado pessoal íntegro sai do perímetro
sem ninguém decidir isso. Falhar explicitamente é o comportamento correto aqui.
// config/9router.json: provedores, tiers e limites
{
"providers": [
{ "id": "anthropic", "type": "anthropic", "api_key_ref": "vault:secret/llm/anthropic" },
{ "id": "vllm-local", "type": "openai_compatible", "base_url": "http://vllm:8001/v1" }
],
"tiers": {
"sensitive": { "models": ["vllm-local/qwen2.5-14b-instruct"], "fallback": false },
"reasoning": { "models": ["anthropic/claude-sonnet", "vllm-local/qwen2.5-14b-instruct"], "fallback": true },
"bulk": { "models": ["vllm-local/qwen2.5-7b-instruct"], "fallback": false }
},
"limits": { "requests_per_minute": 600, "max_tokens_per_request": 8000, "timeout_s": 120 },
"retry": { "max_attempts": 3, "backoff": "exponential_jitter", "on": [429, 500, 502, 503, 529] },
"logging": { "log_prompts": false, "log_usage": true }
}docker run -d --name vllm --gpus all -p 127.0.0.1:8001:8000 \
-v $HOME/.cache/huggingface:/root/.cache/huggingface \
vllm/vllm-openai:latest \
--model Qwen/Qwen2.5-14B-Instruct \
--served-model-name qwen2.5-14b-instruct \
--tensor-parallel-size 2 \
--max-model-len 32768 \
--gpu-memory-utilization 0.90 \
--enable-prefix-caching # reaproveita o bloco fixo de política do prompt
# --enable-prefix-caching não é detalhe de performance: em fluxos de RH,
# 60% a 80% do prompt é o mesmo bloco de políticas e instruções em toda
# chamada. Com cache de prefixo, a latência percebida cai bastante sem
# tocar em nada do agente.ENVIRONMENT=dev # dev | staging | prod
TENANT_DEFAULT=acme
# modelos (9Router)
NINEROUTER_URL=http://9router:20128/v1
NINEROUTER_PASSWORD= # dashboard/JWT, vem do Vault em prod
MODEL_REASONING=anthropic/claude-sonnet
MODEL_CHEAP=local/qwen2.5-14b
MODEL_CLASSIFY=local/qwen2.5-7b
MODEL_EMBED=local/bge-m3
# limites por execução (corte duro no runtime)
BUDGET_TOKENS_PER_RUN=60000
BUDGET_TOOLS_PER_RUN=25
BUDGET_WALLCLOCK_S=180
# HRIS legado (em dev aponta para o mock; a troca é só esta URL)
HRIS_HOST=hris-mock:8080
HRIS_TENANT=acme
HRIS_CLIENT_ID=local-dev
HRIS_TECH_USER=svc_agentes # permissão limitada por campo
# privacidade
PII_MODE=tokenize # tokenize | local_model | raw(proibido em prod)
PII_ENTITIES=CPF,RG,CNH,EMAIL,PHONE,ADDRESS,FULLNAME,BIRTHDATE
RETENTION_CONVERSATION_DAYS=90
RETENTION_TRACE_DAYS=30
RETENTION_AUDIT_DAYS=1825 # 5 anos: trilha sem PII em claro
# n8n
N8N_ENCRYPTION_KEY=
EXECUTIONS_MODE=queue
N8N_DIAGNOSTICS_ENABLED=false
N8N_PAYLOAD_SIZE_MAX=16.env guarda endereços e chaveamentos, nunca segredo de produção. Segredo vem do
Vault em tempo de execução, com política de leitura mínima por serviço e identidade por AppRole com
TTL curto. O que está no .env pode aparecer em log de build sem consequência, e essa é
exatamente a linha que define o que entra nele.
Este é o coração do sistema, e vale ler tabela por tabela:
cada uma existe por causa de uma falha concreta em produção.
idempotency existe por causa de chamado duplicado; outbox por causa de
escrita perdida entre commit e publicação; agent_steps por causa de
"por que ele fez isso?" sem resposta.
Retentativa: com e sem chave de idempotência
O cenário real: um timeout numa chamada que na verdade tinha sido concluída. Clique em abrir chamado várias vezes, com o interruptor ligado e desligado, e conte quantos equipamentos a mesma pessoa recebe.
CREATE TABLE agent_runs (
run_id TEXT PRIMARY KEY, -- ULID, também é o trace_id
tenant TEXT NOT NULL,
agent TEXT NOT NULL,
agent_version TEXT NOT NULL,
prompt_hash TEXT NOT NULL, -- qual prompt exatamente rodou
model_used TEXT NOT NULL, -- resolvido pelo 9Router
subject_id TEXT, -- titular do dado (para DSR)
requester_id TEXT NOT NULL, -- quem pediu
status TEXT NOT NULL, -- running|awaiting_approval|done|failed|escalated
input_ref TEXT NOT NULL, -- ponteiro no MinIO, já tokenizado
output_ref TEXT,
tokens_in INT DEFAULT 0,
tokens_out INT DEFAULT 0,
cost_usd NUMERIC(10,4) DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ
);
CREATE INDEX ON agent_runs (tenant, agent, started_at DESC);
CREATE INDEX ON agent_runs (subject_id); -- eliminação do titular em O(log n)
CREATE TABLE agent_steps (
step_id BIGSERIAL PRIMARY KEY,
run_id TEXT REFERENCES agent_runs ON DELETE CASCADE,
seq INT NOT NULL,
kind TEXT NOT NULL, -- thought|tool_call|tool_result|output|error
tool_name TEXT,
tool_args JSONB, -- sem PII: valores tokenizados
tool_result JSONB,
latency_ms INT,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (run_id, seq)
);CREATE TABLE idempotency (
key TEXT PRIMARY KEY, -- hash(run_id, task_code, employee_id)
tool_name TEXT NOT NULL,
response JSONB NOT NULL, -- resposta memorizada
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|sent|dead
attempts INT DEFAULT 0,
next_try_at TIMESTAMPTZ DEFAULT now(),
last_error TEXT
);
CREATE INDEX ON outbox (status, next_try_at);CREATE TABLE approvals (
approval_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES agent_runs,
proposal JSONB NOT NULL, -- o que o agente quer fazer
diff JSONB NOT NULL, -- o que muda no mundo real
decided_by TEXT,
decision TEXT, -- approved|rejected|edited|timeout_auto
edits JSONB, -- sinal de ouro para a suíte de eval
requested_at TIMESTAMPTZ DEFAULT now(),
decided_at TIMESTAMPTZ
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor TEXT NOT NULL, -- usuário, agente ou sistema
action TEXT NOT NULL,
resource TEXT NOT NULL,
run_id TEXT,
detail JSONB, -- nunca PII em claro
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL -- encadeamento: detecta alteração
);
REVOKE UPDATE, DELETE ON audit_log FROM app_user; -- append-only na práticaA política que o RAG devolve depende da data
Três campos de metadado mudam a qualidade da
resposta mais do que qualquer ajuste de chunk size: versao,
vigencia e publico_alvo. Escolha o público e a data de referência e veja
qual versão da política de férias entra no prompt.
Um sistema agêntico tem dois contratos, e confundi-los é o erro mais comum. O contrato interno é estruturado, validável e nunca é texto livre. O contrato de apresentação é o que o colaborador lê, e é derivado do primeiro, não produzido diretamente pelo modelo. O agente devolve dados; a camada de apresentação escreve a mensagem.
As quatro validações de borda, antes de gastar o primeiro token
Monte uma requisição defeituosa e veja qual regra a derruba. A primeira é o vetor de ataque número um em assistente de RH.
Um objeto, três canais, zero chamada extra ao modelo
A camada de apresentação é determinística: recebe o objeto validado e monta a mensagem com componentes de UI, sem chamar o modelo de novo. Assim o tom é consistente, a formatação nunca quebra e a mesma resposta serve portal, WhatsApp e e-mail a partir de uma fonte única.
citations é obrigatório em toda resposta
sobre política: sem fonte verificável, a resposta é recusada pelo próprio validador. E
evidence separa o que o sistema leu do que o modelo escreveu, que é o que permite a um
analista dizer, seis meses depois, de onde veio aquele número.
As regras de apresentação, e o porquê de cada uma
Quando dá errado: estados e o que a pessoa lê
Cada estado tem mensagem escrita por gente, revisada com o RH. Nunca é o modelo que improvisa a desculpa. Nenhuma mensagem de erro expõe nome de sistema, stack ou id técnico: o id vai no rodapé, para o suporte.
Em sistema tradicional, observabilidade responde "está no ar e rápido?". Em sistema agêntico ela precisa responder "por que ele decidiu isso, com que dado, sob qual versão de prompt, e quanto custou?", e precisa responder meses depois, em auditoria. São duas exigências diferentes, e por isso são dois pipelines.
Observabilidade
Traces, métricas e logs. Pergunta: está saudável, rápido, dentro do custo? Retenção curta, amostragem permitida, acesso da engenharia.
Rastreabilidade
Trilha de decisão encadeada e íntegra. Pergunta: quem pediu, o que o sistema leu, quem aprovou? Sem amostragem, retenção longa, acesso restrito.
Os cinco identificadores, e onde cada um nasce
A trilha de auditoria, com SHA-256 rodando aqui
Cada registro carrega o hash do anterior. Altere um evento antigo e a cadeia inteira invalida. Os hashes abaixo são calculados pelo seu navegador com Web Crypto, não são enfeite: edite qualquer linha e rode a verificação.
def run_agent(agent, payload, ctx):
with tracer.start_as_current_span("agent.run") as span:
span.set_attributes({
"agent.name": agent.name,
"agent.version": agent.version,
"agent.prompt_hash": agent.prompt_hash,
"llm.tier": agent.tier,
"tenant": ctx.tenant,
"run_id": ctx.run_id,
"subject_id_hash": sha256(ctx.subject_id), # nunca o id em claro
})
lf_trace = lf.trace(id=ctx.run_id, name=agent.name,
user_id=sha256(ctx.requester_id), tags=[ctx.channel])
try:
for step in agent.loop(payload, ctx):
with tracer.start_as_current_span(f"agent.step.{step.kind}") as s:
s.set_attributes({
"step.seq": step.seq,
"step.kind": step.kind, # thought|tool_call|…
"tool.name": step.tool_name or "",
"tool.duration_ms": step.latency_ms or 0,
"llm.model": step.model or "", # resolvido pelo 9Router
"llm.tokens.in": step.tokens_in,
"llm.tokens.out": step.tokens_out,
"llm.cost_usd": step.cost_usd,
})
lf_trace.span(name=step.kind, input=step.redacted_input,
output=step.redacted_output, metadata=step.meta)
db.insert_step(ctx.run_id, step) # rastreabilidade
span.set_attribute("agent.status", "done")
except BudgetExceeded as e:
span.set_attribute("agent.status", "budget_exceeded"); raise
except ToolFailure as e:
span.record_exception(e); span.set_attribute("agent.status", "failed"); raise{"ts":"2026-08-15T14:22:07.412Z","level":"info","svc":"agent-runtime",
"event":"tool_call","run_id":"01J8ZQK3M7V9X2NRT4","step":3,
"tool":"hris.get_time_balance","args":{"employee_id":"<PESSOA_1>"},
"outcome":"ok","latency_ms":412,"tenant":"acme","agent":"atendimento@2.7.1"}
{"ts":"2026-08-15T14:22:09.880Z","level":"warn","svc":"agent-runtime",
"event":"schema_retry","run_id":"01J8ZQK3M7V9X2NRT4","step":5,
"reason":"citations_missing","attempt":1}
# O que NUNCA entra em log: texto de entrada do usuário em claro,
# conteúdo de documento recuperado, valores de PII, token de
# autenticação, resposta completa do modelo. O log referencia; o
# conteúdo fica no armazenamento com acesso e retenção próprios.
# Um pii_scan roda contra a saída de log no CI e quebra o build se
# encontrar padrão de CPF, e-mail ou telefone.# services/audit.py
def append(actor, action, resource, run_id=None, detail=None):
prev = db.last_audit_hash() or "GENESIS"
body = canonical_json({"at": now_iso(), "actor": actor, "action": action,
"resource": resource, "run_id": run_id,
"detail": scrub_pii(detail)})
h = sha256(prev + body)
db.insert_audit(body=body, prev_hash=prev, hash=h)
return h
# verificação da cadeia (cron e antes de qualquer auditoria externa)
make audit-verify FROM=2026-01-01
# ✓ 1.284.902 registros · cadeia íntegra · último hash 9f3c…a71bMétricas, alvos e o que fazer quando estoura
make trace para ver o passo a
passo real, make replay para reexecutar contra o código atual, make replay
MODEL=… para testar a hipótese, make eval-add para virar teste e
make eval SUITE=… para confirmar que o resto não regrediu. É esse ciclo que faz a
plataforma melhorar em vez de só operar: todo incidente termina como caso permanente na
suíte, e por isso o mesmo erro não volta duas vezes.
O processo de admissão era coordenado por planilha e e-mail. Para cada contratação, o RH abria à mão pendências em quatro sistemas: cadastro no RH legado, provisionamento de acessos, pedido de equipamento e agenda de integração. O sintoma que motivou o projeto não foi custo: era a taxa de admissões em que algo chegava errado ou atrasado no primeiro dia.
O fluxo, percorrido etapa por etapa
Cinco problemas reais, e o que resolveu cada um
[PREENCHER]: tempo até produtivo, percentual de admissões sem pendência no dia 1, horas
de trabalho manual devolvidas ao RH por mês e percentual de planos aprovados sem edição. Estrutura,
desafios e formato da métrica são reaproveitáveis; número inventado não é.
Todo RH tem um sistema de origem, e o nome dele muda de empresa para empresa. O que não muda é o formato do problema: modelo de dados próprio e muito grande, regras de efetividade temporal e permissões por campo. A integração falha quando se tenta tratá-lo como um CRUD. São sete etapas, e a armadilha mora na quarta.
hris-mock sobe junto no compose e
serve o $metadata, as entidades com vigência e o fluxo de token a partir de fixtures em
JSON. Você monta a integração inteira sem sandbox de fornecedor nenhum. Quando for apontar para o
sistema real, muda a URL base no .env e o contrato do servidor MCP continua o mesmo,
que é exatamente o motivo de a camada anticorrupção existir.
As sete etapas da integração
Effective dating: o mesmo colaborador, várias versões válidas
Consultar sem asOfDate devolve a
versão errada em promoções e transferências futuras, e o agente informa um cargo que ainda não
vale. Mova a data e veja qual registro o cliente devolve.
from mcp.server.fastmcp import FastMCP
from .hris_client import HRISClient
from .policy import require_scope, audit
mcp = FastMCP("hris-mcp")
hris = HRISClient()
@mcp.tool()
@require_scope("hr.read")
@audit
async def get_time_balance(employee_id: str, ctx) -> dict:
"""Saldo de férias e banco de horas do colaborador.
Só retorna dados do próprio usuário ou de subordinados diretos."""
if not ctx.can_read(employee_id):
raise PermissionError("fora do escopo do solicitante")
raw = await hris.odata(
"TimeAccountBalance",
select=["employeeId", "balance", "timeAccountType", "asOfDate"],
filter=f"employeeId eq '{employee_id}'",
)
return TimeBalance.from_hris(raw).model_dump() # modelo de domínio
@mcp.tool()
@require_scope("hr.write")
@audit(approval="required")
async def upsert_employee(payload: EmployeeUpsert, idempotency_key: str) -> dict:
"""Escreve no HRIS. Idempotente por idempotency_key."""
if cached := await dedupe.get(idempotency_key):
return cached # devolve o resultado original
result = await hris.upsert("PersonalInfo", payload.to_hris())
await dedupe.put(idempotency_key, result, ttl="7d")
return result# 1) obter a SAML assertion (assinada com a chave do OAuth client)
ASSERTION=$(curl -s -X POST "https://$HRIS_HOST/oauth/idp" \
-d "client_id=$HRIS_CLIENT_ID" \
-d "user_id=$HRIS_TECH_USER" \
-d "token_url=https://$HRIS_HOST/oauth/token" \
-d "private_key=$(vault kv get -field=key secret/hris/oauth)")
# 2) trocar por access token de vida curta
TOKEN=$(curl -s -X POST "https://$HRIS_HOST/oauth/token" \
-d "tenant_id=$HRIS_TENANT" \
-d "client_id=$HRIS_CLIENT_ID" \
-d "grant_type=urn:ietf:params:oauth:grant-type:saml2-bearer" \
-d "assertion=$ASSERTION" | jq -r .access_token)
# Basic auth resolve o piloto e não passa em auditoria. A chave privada
# fica no Vault, nunca em variável de ambiente do contêiner, e o token
# é cacheado no Redis com margem de renovação antes da expiração.
#
# O hris-mock implementa os dois endpoints com uma chave de teste, então
# o fluxo que você escreve em dev é o mesmo que vai para produção.# descoberta do metadata e das entidades disponíveis
curl -s -u "$HRIS_USER@$HRIS_TENANT:$HRIS_PASS" \
"https://$HRIS_HOST/odata/v2/\$metadata" -o metadata.xml
xmllint --xpath "//*[local-name()='EntityType']/@Name" metadata.xml
# leitura enxuta, com paginação por cursor. Nunca SELECT *:
# $select explícito reduz payload, latência e superfície de
# exposição de dado pessoal de uma vez só.
curl -s "https://$HRIS_HOST/odata/v2/JobHistory?\
\$select=userId,jobTitle,department,managerId,startDate&\
\$filter=lastModifiedDateTime gt datetime'2026-08-01T00:00:00'&\
\$top=200&\$format=json" -H "Authorization: Bearer $TOKEN"Segurança em camadas
A diferença para um pipeline comum é que o comportamento do sistema muda sem que o código mude: trocou o modelo, mudou o prompt, mudou o documento indexado, e a saída é outra. Por isso prompt, política, dataset de avaliação e versão de modelo são artefatos versionados, com o mesmo rigor do código.
Rode o pipeline com as suas condições
Marque o que mudou nesta release e mande rodar. Cada portão tem critério de bloqueio próprio, e o primeiro que falha derruba o deploy.
temperature=0 e ainda assim com N repetições, e compare intervalos, não valores únicos.
Um teste que passa 4 de 5 vezes não passou. Antes do canário vem o shadow mode: a
versão nova processa tráfego real em paralelo, sem efeito colateral, e a diferença de decisão em
relação à produção é medida. Só promove quem não regride.
Testes por camada, e o critério que bloqueia
stages: [static, test, eval, security, deploy]
evals:
stage: eval
image: registry.interno/agent-ci:2.4
script:
- promptfoo eval -c evals/hr-assistant.yaml --output out/evals.json
- python ci/compare_baseline.py out/evals.json \
--baseline baselines/$AGENT-$MODEL.json \
--max-regression 2 --min-tool-accuracy 0.95
artifacts: { paths: [out/], expire_in: 90 days }
red_team:
stage: security
script:
- python ci/redteam.py --suite prompt-injection --fail-on any
- python ci/pii_scan.py --paths out/ logs/ --fail-on any
- gitleaks detect --no-git --redact
deploy_canary:
stage: deploy
when: manual # revisão humana obrigatória
script:
- make n8n-import DIR=./workflows ENV=prod
- make canary AGENT=$AGENT WEIGHT=5
- python ci/watch_slo.py --window 30m --rollback-on breachLGPD embutida no pipeline, não no PDF de política
Conformidade que depende de disciplina individual falha. O que funciona é transformar cada exigência em um controle automatizado que quebra o build.
Começa por uma decisão de produto, porque ela determina o resto: o agente não decide sobre pessoas. Ele estrutura evidência, verifica requisitos objetivos, organiza informação e devolve isso a um humano que decide. Essa fronteira reduz o risco de viés mais do que qualquer técnica algorítmica, e é também a leitura que o art. 20 da LGPD e as regulações de IA de alto risco pedem.
De onde o viés entra
O teste contrafactual, que é o mais simples e o mais informativo
Pegue o mesmo currículo, troque apenas o atributo sensível e verifique se a saída muda. Isso roda no CI a cada mudança de prompt ou de modelo, e bloqueia o deploy. Ligue a triagem cega e veja os pares convergirem.
Em produção: medir o resultado, não a intenção
Taxa de seleção por grupo em cada etapa do funil, com a regra dos 4/5 como alarme: se a taxa de um grupo fica abaixo de 80% da do grupo de maior taxa, abre-se investigação. A conta abaixo é real, mexa nos números.
Chegou no fim. Falta o que separa uma demonstração de um sistema em produção: o checklist que você assina antes de ligar para gente de verdade. Do dia um: idempotência, aprovação humana, log de auditoria e kill switch. O resto entra por necessidade, mas essas quatro são muito mais caras de adicionar depois do primeiro incidente do que antes dele.
Checklist de go-live
name: onboarding
version: 3.2.0
model:
primary: claude-sonnet
fallback: qwen-14b-local
temperature: 0
max_tokens: 4000
budget: { tokens_per_run: 60000, tools_per_run: 25, wallclock_s: 180 }
prompt_file: prompts/onboarding.v3.md # hash registrado no deploy
tools:
- hris.get_employee
- docs.search
- itsm.open_ticket
- comms.send_email
- hris.upsert_employee
policy:
approval_required: [hris.upsert_employee, comms.send_email]
approval_skip_if: "risk_level == 'low' and elapsed > 4h"
pii_mode: tokenize # local_model | tokenize | raw
output_schema: schemas/onboarding_plan.json
on_schema_error: retry_once_then_escalate
deny_topics: [orientacao_juridica, negociacao_salarial, desligamento]
escalation:
to: "fila-rh-onboarding"
when: [tool_failure_final, low_confidence, deny_topic, budget_exceeded]
eval:
suite: evals/onboarding.yaml
gates: { tool_accuracy: 0.95, rubric_score: 8.5, pii_leaks: 0 }services:
n8n-main:
image: n8nio/n8n:latest
environment:
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
DB_TYPE: postgresdb
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_DIAGNOSTICS_ENABLED: "false" # nada sai do perímetro
depends_on: [postgres, redis]
n8n-worker:
image: n8nio/n8n:latest
command: worker --concurrency=10
deploy: { replicas: 4 }
agent-runtime:
build: ./runtime
environment:
MODEL_ROUTER_URL: http://9router:20128/v1
MCP_SERVERS: hris-mcp,docs-mcp,itsm-mcp,comms-mcp
PII_GATEWAY_URL: http://pii-gw:8080
LANGFUSE_HOST: http://langfuse:3000
9router: { image: decocua/9router:latest, ports: ["127.0.0.1:20128:20128"] }
vllm: { image: vllm/vllm-openai:latest }
hris-mcp: { build: ./mcp/hris }
hris-mock: { build: ./mocks/hris } # só sobe com --profile dev
docs-mcp: { build: ./mcp/docs }
pii-gw: { build: ./services/pii-gateway }
postgres: { image: postgres:16 }
redis: { image: redis:7-alpine }
qdrant: { image: qdrant/qdrant }
minio: { image: minio/minio }
vault: { image: hashicorp/vault }
langfuse: { image: langfuse/langfuse:2 }// workflows/onboarding-v3.json: recorte do nó que chama o runtime
{
"name": "Agente: montar plano",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "http://agent-runtime:8000/v1/agents/onboarding/run",
"sendHeaders": true,
"headerParameters": { "parameters": [
{ "name": "Idempotency-Key", "value": "={{ $json.execution_id }}" },
{ "name": "Authorization", "value": "=Bearer {{ $json.user_token }}" }
]},
"jsonBody": "={{ JSON.stringify({ input: $json.candidate, trace_id: $json.trace_id }) }}",
"options": { "timeout": 180000, "retry": { "maxTries": 3, "waitBetween": 5000 } }
},
"onError": "continueErrorOutput" // saída de erro vai para a DLQ
}As perguntas que sempre vêm em seguida
O runbook inteiro, em treze linhas