Por dentro do harness do Fackel: lanes de agentes paralelos no terminal
Índice
O Fackel é um assistente de segurança ofensiva movido a IA. Por baixo do capô ele roda múltiplos agentes especialistas em paralelo, transmite o raciocínio deles ao vivo, pausa para aprovação humana antes da varredura ativa e persiste cada achado num grafo de conhecimento consultável.
Este post é sobre o harness de terminal que torna esse fluxo utilizável. (Para o cérebro por trás dele—o orquestrador LangGraph, os agentes ReAct e o LLM-como-juiz que roteia entre as fases—veja o post anterior.)

A ideia que vale a pena construir
O interessante no Fackel não é a lista de comandos. É isto: vários agentes especialistas raciocinando ao mesmo tempo, dentro de um único terminal interativo sobre o qual você mantém o controle.
Essa frase esconde três problemas difíceis. Agentes concorrentes brigam por um único terminal. Uma etapa de aprovação humana precisa interromper trabalho que está rodando em outra thread. E uma execução longa de agente enche sua janela de contexto, então você precisa ver isso acontecer e poder fazer algo a respeito. O harness é a camada que resolve esses três problemas—tudo abaixo é como.
Por que uma thread worker e uma fila, não async
A primeira decisão é o modelo de concorrência. Um scan é demorado e gera trabalho próprio; o terminal precisa continuar responsivo. O reflexo óbvio é asyncio, mas o Fackel roda o LangGraph em modo síncrono, onde os especialistas paralelos executam em threads, não em corrotinas. Acoplar um event loop em cima disso não traz ganho algum e complica o cancelamento.
Então o harness usa uma separação produtor/consumidor simples:
thread principal thread worker (daemon)
┌─────────────┐ queue.Queue ┌──────────────────────┐
│ prompt + │◀───────────────────│ orchestrator.run() │
│ Rich Live │ eventos (phase, │ → threads de │
│ (dono único)│ type, data) │ especialistas │
└─────────────┘ └──────────────────────┘
│ __approval__ / __done__ / __error__ / __cancelled__
Um scan roda numa thread worker daemon. Cada evento—um token de raciocínio de um agente, um resultado de ferramenta, uma fronteira de fase—é empurrado para uma queue.Queue. A thread principal drena essa fila e é a dona única do display Live do Rich e do prompt. Essa regra de dono único importa: a região live do Rich e o prompt_toolkit ambos assumem um único escritor. Deixar threads worker desenharem direto no terminal intercalaria sequências de escape e corromperia o quadro. A fila é a costura que mantém a renderização numa thread e o trabalho na outra.
A linha de ContextVars que faz tudo funcionar
Há um detalhe que carrega o peso. A fiação de streaming—o callback de eventos e a flag de cancelamento cooperativo—é vinculada via contextvars dentro de um context manager run_session. Uma threading.Thread crua não herda os ContextVars do pai, então a worker começaria cega: sem callback para emitir, sem flag de cancelamento para observar. A correção é uma linha:
ctx = contextvars.copy_context()
worker = threading.Thread(target=lambda: ctx.run(_worker), name="fackel-scan", daemon=True)
Copiar o contexto depois que os bindings do run_session estão no lugar significa que a worker—e as threads de especialistas que o LangGraph gera a partir dela—todas enxergam o mesmo callback e a mesma flag de cancelamento. Esqueça isso, e a tela fica vazia enquanto o scan roda perfeitamente no escuro.
Especialistas paralelos, e por que a tela tem lanes
OSINT e varredura de vulnerabilidades não rodam como um agente único e grande. Eles se ramificam. Cada fase dispara um conjunto de sub-agentes especialistas focados via Send do LangGraph, e eles rodam em paralelo:
@dataclass(frozen=True)
class Specialist:
"""Um sub-agente OSINT focado: um domínio, um foco de tarefa e suas ferramentas."""
name: str
focus: str
tool_names: tuple[str, ...]
OSINT se divide em especialistas como dns_infra (DNS, WHOIS, ASN, reputação de IP) e subdomains (enumeração, filtragem de wildcard, detecção de takeover, coleta de TLS SAN). A varredura de vulnerabilidades se divide em surface, nuclei, web_injection, app_config e mais—cada um com seu próprio subconjunto de ferramentas e string de focus. Eles executam concorrentemente e convergem de volta por canais de merge dedicados no estado compartilhado.
Concorrência é ótima para latência e terrível para um log que rola: cinco agentes emitindo raciocínio ao mesmo tempo produzem um entrelaçamento ilegível. Então cada especialista vincula uma lane antes de transmitir:
with streaming.lane(name):
streaming.emit("osint", "lane_start", {"name": name})
...
Cada evento carrega essa tag de lane, e o renderer mantém estado por lane—um _LaneState por agente, nunca um buffer compartilhado. Especialistas paralelos renderizam em lanes separadas (a tabela que você vê na demo acima); fases sequenciais colapsam numa única lane main com a visão mais rica de painel de raciocínio. Há uma área Live do Rich por fase; quando a fase termina, os resumos finalizados são impressos acima da região live e a tabela de lanes transitória desaparece. Você vê o trabalho acontecer, e depois fica com um transcript limpo do que aconteceu.
O trade-off honesto: aprovação por ferramenta (--approve-tools) força o caminho monolítico, não-paralelo. Ramos paralelos não conseguem compartilhar de forma coerente um único stream de interrupção de aprovação, então pedir para aprovar cada chamada de ferramenta custa as lanes. Isso é uma limitação real, não um bug—dois recursos que genuinamente conflitam.
Gates de aprovação inline sem congelar o loop de renderização
Antes de qualquer varredura ativa, um gate human-in-the-loop pausa a execução e mostra os alvos descobertos. O detalhe: a thread worker decide que precisa de aprovação, mas só a thread principal pode ler entrada de teclado. O handshake é uma pequena dataclass carregando um threading.Event:
@dataclass
class _PendingApproval:
data: dict[str, Any]
kind: str # "gate" | "tool"
event: threading.Event = field(default_factory=threading.Event)
result: Any = None
A worker enfileira um item __approval__ e bloqueia em box.event.wait(). A thread principal vê o item, desmonta a região live, renderiza o gate (ou a ferramenta específica e seus argumentos), lê um sim/não com prompt_toolkit.confirm, escreve a resposta de volta na box e seta o event. A worker desbloqueia com sua decisão. O mesmo mecanismo serve tanto o gate de fase quanto a aprovação por ferramenta—kind é a única diferença. A aprovação nunca bloqueia o loop de renderização, porque o loop de renderização é quem a responde.
Cancelamento cooperativo, não um kill
Apertar Ctrl-C durante um scan não mata a worker. Seta uma flag de cancelamento e continua drenando a fila:
except KeyboardInterrupt:
cancel.set()
self._console.print("stopping…")
continue
A flag viaja no mesmo ContextVar que as threads de especialistas herdaram, então elas a percebem no próximo checkpoint e se desfazem de forma limpa; a worker então reporta __cancelled__ de volta pela fila. Um kill duro deixaria o checkpoint do LangGraph e a camada de persistência num estado desconhecido. O cancelamento cooperativo custa um instante de latência e compra uma sessão em que você pode confiar depois—o scan ou completa, ou falha, ou cancela, e o REPL sobrevive aos três. No prompt (não no meio de um scan), Ctrl-C só limpa a linha e Ctrl-D sai.
O medidor de contexto
Janelas de contexto de LLM são finitas, e um scan longo com ferramentas tagarelas as enche. O harness rastreia uma estimativa contínua de tokens a partir dos eventos transmitidos e a renderiza como uma barra compacta na toolbar inferior:
_COUNTED_EVENTS = frozenset({"token", "reasoning", "reasoning_trace", "tool_result", "summary"})
Crucialmente, ele reutiliza o próprio estimador text_tokens do orquestrador em vez de inventar uma segunda contagem, então o medidor se alinha com a guard-rail de trimming que o pipeline já aplica. /context quebra o total por fase; /compact resume os achados anteriores na memória da sessão e reseta o medidor ao vivo, para que uma sessão com vários scans não arraste todo o seu histórico para frente.
A sessão é uma bancada de trabalho
Quando um scan termina, o harness não sai—ele te devolve um prompt sobre tudo que acabou de acontecer. Essa é a parte em que você passa mais tempo:
| Comando | O que faz |
|---|---|
/scan <alvo> [--no-active] [--approve-tools] | Executa um scan |
/ask <pergunta> | Pergunta ao grafo de conhecimento do último scan em linguagem natural |
/scans · /diff <antigo> <novo> | Lista scans persistidos · compara dois deles (ativos novos / resolvidos / alterados) |
/graph [scan_id] | Exporta o grafo de conhecimento como Mermaid |
/context · /compact | Inspeciona o medidor de tokens ao vivo · resume achados na memória da sessão |
/model [provider] [nome] | Vê ou troca o LLM (persiste no .env) |
Texto puro, sem barra inicial, é tratado como /ask, porque depois de rodar um scan a coisa mais comum que você quer fazer é interrogá-lo. Um erro de comando nunca derruba o REPL—cada handler é envolvido, então um argumento ruim em /diff imprime uma linha vermelha e te devolve ao prompt em vez de encerrar a sessão.
Algumas escolhas menores também merecem seu lugar:
/modelpersiste. Trocar provider ou modelo grava a escolha no.env, então ela sobrevive à sessão. Quando o novo provider precisa de uma chave que não está setada, o harness a pede sem eco—numaPromptSessionnova, para que a flag de senha nunca contamine o prompt compartilhado do REPL e comece a mascarar tudo que você digita. Melhor pedir a chave agora do que deixar o próximo scan falhar por falta de credencial.- Nerd Font com fallback. Os glyphs são Nerd Font por padrão; defina
FACKEL_NERD_FONT=0e cada glyph cai para um símbolo ASCII de largura 1 que mantém as colunas das tabelas alinhadas. A TUI não deveria quebrar só porque seu terminal não tem uma fonte com patch.
O que eu faria diferente
Aprovação por ferramenta não deveria custar as lanes. A correção mais limpa é um único canal de aprovação serializado pelo qual todos os ramos paralelos possam afunilar, mantendo tanto a granularidade human-in-the-loop quanto a visão paralela. É um trabalho real—coordenar um único stream de interrupção entre ramos de fan-out—que é exatamente por que ainda não está feito.
O protocolo da fila é tipado por strings. Os eventos são tuplas (phase, type, data) com cabeças sentinela como __done__ e __approval__. Funciona, mas um pequeno envelope tipado tornaria o contrato produtor/consumidor explícito e pegaria um evento malformado na fronteira, em vez de fundo dentro do renderer.
O medidor de tokens é uma estimativa. Ele reutiliza o estimador do orquestrador por consistência, mas ainda é uma heurística, não a contabilidade real do provider. Reconciliá-lo com o uso real do trace deixaria a barra confiável o bastante para impor limites rígidos.
Por que o harness existe
Segurança ofensiva é iterativa. Você não roda um scan e vai embora—você investiga, compara com a semana passada, faz perguntas, refina uma hipótese e escaneia de novo. A maior parte desse ciclo acontece entre os scans, na parte que uma ferramenta de execução única joga fora.
O propósito inteiro da cabine do Fackel é fazer esse ciclo parecer natural mantendo o operador no controle: os agentes fazem o trabalho braçal em paralelo e transmitem o que estão pensando, mas você aprova os passos ativos, acompanha o orçamento de contexto e decide o que perseguir em seguida.
git clone https://github.com/flaviomilan/fackel.git
cd fackel && uv sync --python 3.12
cp .env.example .env # defina OPENAI_API_KEY
fackel # harness interativo
Se isso parece útil, experimente e me conte o que quebrar. Open source sob Apache 2.0: github.com/flaviomilan/fackel.
Comentários