Por dentro do harness do Fackel: lanes de agentes paralelos no terminal

🇺🇸 Read in English
Í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.)

O harness do Fackel: lanes de agentes paralelos, um stepper de pipeline ao vivo e gates de aprovação inline rodando no terminal.

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:

ComandoO 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 · /compactInspeciona 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:

  • /model persiste. 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—numa PromptSession nova, 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=0 e 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