Trocar a camada de interação¶
O núcleo do flower não sabe que a UI existe. Tudo o que acontece dentro de uma run — o modelo falando, chamando uma ferramenta, o contexto quase cheio, a necessidade de perguntar algo a uma pessoa — é achatado na mesma estrutura de dados: Event. A camada de interação só conhece Event; ela não importa nenhum tipo do SDK. É essa a fronteira que permite trocar a UI sem mexer no núcleo: terminal, Web, serviço HTTP, execução totalmente automática sem supervisão — o que muda é o consumidor dos Event, e nada mais precisa ser alterado.
Que problema resolve¶
O fluxo de mensagens do SDK usa tipos internos: AssistantMessage, ToolUseBlock, ToolResultBlock, ResultMessage, SystemMessage… Consumi-los diretamente na UI tem duas consequências: a cada atualização do SDK o front-end precisa acompanhar; e como cada tipo de mensagem tem um formato diferente, cada UI precisa reescrever do zero a lógica de "isto é texto ou uma chamada de ferramenta".
normalize(message) converte uma mensagem do SDK em 0 a N Event (core/events.py). O custo é uma conversão; o ganho é não haver dependência de tipos entre a camada de interação e o SDK.
Essa fronteira ainda resolve, de quebra, quatro coisas menos óbvias — todas dentro de normalize():
- As falas dos subagents ficam marcadas (
payload["subagent"]). Sem isso, a task brief enviada e as falas intermediárias do subagent se misturariam ao texto da main thread e, seguindo o workflow, contaminariam o prompt do passo seguinte. - As mensagens de erro sintéticas geradas em quedas de conexão são desviadas para
kind="error". Ao cair a conexão, o lado do SDK escreveAPI Error: …no transcript como se fosse uma mensagem de assistant, e ela se parece com fala do modelo (modelé"<synthetic>"). Se não for barrada aqui, entra emStepResult.texte é repassada ao step seguinte. - As fronteiras de compact são reportadas explicitamente (
kind="reset"). Depois da fronteira, o que o modelo "lembra" é apenas o resumo, e o cache de prompt também é cortado ali — uma run long-horizon precisa poder enxergar isso. - O nível de contexto sai junto de cada mensagem (
payload["context"]=input_tokens+cache_read_input_tokens+cache_creation_input_tokens). É a única fonte do critério de handoff.
Como usar (código mínimo)¶
Uma camada de interação precisa ligar três coisas: saída de eventos (para onde renderizar), canal de perguntas (quem responde) e interrupção (como mandar parar). O trecho abaixo liga as três e roda direto:
import asyncio
from flower import Event, HumanChannel, Runtime, starter_flow
def sink(ev: Event) -> None:
"""Renderiza o Event na sua própria UI — é a única coisa que precisa ser trocada."""
if ev.kind == "step":
print(f"\n=== {ev.text} ({ev.payload['index']}/{ev.payload['total']}) ===")
elif ev.kind == "text" and not ev.payload.get("subagent"):
print(ev.text)
elif ev.kind == "tool_call":
print(f" [{ev.tool}] {ev.text}")
elif ev.kind == "handoff":
print(f" ~ 换代/{ev.payload.get('phase')}: {ev.text}")
elif ev.kind == "retry":
print(f" ~ 重试: {ev.text}")
elif ev.kind == "ask" and ev.payload.get("kind") == "mail":
print(f" ~ 人主动说:{ev.text}")
# kind == "ask" que não seja mail fica com o answerer abaixo (modo pull)
async def answerer(ch: HumanChannel) -> None:
"""Busca perguntas em modo pull. Ao migrar para Web / HTTP, esta corrotina é o outro ponto a mudar."""
while True:
ask = await ch.next_ask() # sem timeout, espera indefinidamente
if ask is None:
continue
print(f"\n?? {ask.question} 选项={ask.options}")
ch.answer(ask.id, "按你的判断来") # ou ch.decline(ask.id, "先跳过")
async def main() -> None:
wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=60)
# O Runtime usa o workbench que o próprio workflow criou — não monte outro
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
task = asyncio.create_task(answerer(wf.channel))
try:
ctx = await wf.run(rt, on_event=sink)
finally:
task.cancel()
rt.close() # fecha a conexão SQLite
print(rt.total_cost(), ctx.get("_failed_at"))
asyncio.run(main())
Dois detalhes de finalização fáceis de esquecer: rt.close() sempre no finally; e se ctx["_failed_at"] tiver valor, a run parou no meio (on_fail="stop") — não trate como sucesso.
Só existe um workbench; não monte outro
O workbench criado por Runtime(workbench=True) fica em <run_dir>/workbench, enquanto Workbench(ws) fica por padrão em <ws>/.flower — não são o mesmo diretório. Se o programa que dirige a execução montar o caminho por conta própria para achar 需求.md, você acaba com "o brief escrito no diretório A e o índice injetado varrendo o diretório B", sem nenhum erro. Ou passe para o Runtime aquele que o workflow criou (como acima), ou use a sondagem somente leitura wake_state() para perguntar onde ele está.
As três saídas de evento¶
await rt.run(spec, "…", on_event=sink) # 1. um único agent
await wf.run(rt, on_event=sink, on_step=progress) # 2. o workflow inteiro, repassado a cada passo
wf = Workflow(steps=[...], channel=ch) # 3. o canal de perguntas, ligado à mesma saída
A terceira ligação acontece dentro de Workflow.run: só é feita automaticamente quando on_event não é None e channel.on_event ainda é None. Se você já ligou a sua, ela não é sobrescrita:
on_step(step, result) é outro callback, chamado uma vez ao fim de cada passo (inclusive em falha), recebendo o StepResult completo. Barra de progresso, gravação em disco e alertas vão aqui; não tente montar isso a partir do fluxo de Event — o texto é quebrado em vários pedaços por handoff e retry.
Terminal: o padrão¶
Já existe um, sem escrever código. flower "帮我做一个 X" passa por flower/cli.py, que é a implementação de referência da camada de interação, não parte do framework; pode ser substituída inteira. Para os flags, veja a referência da CLI. Falando de tamanho com honestidade: o cli.py inteiro tem 1264 linhas, 57KB — mas não é o arquivo inteiro que precisa ser trocado. O ponto real de substituição é a class Render lá dentro (cli.py:489-687, 197 linhas), cuja docstring diz exatamente isso: "Event → terminal. Trocar a UI é trocar esta única classe." As outras mil e tantas linhas são interrupção, oracle, recibos de caixa de entrada, resgate por sinal — acessórios específicos do terminal que, ao migrar para Web ou HTTP, você não copiaria de qualquer forma.
Ou seja, a afirmação "cerca de 200 linhas substituíveis em bloco" se sustenta — desde que se refira a Render, e não ao cli.py.
Se você escrever a sua própria UI de terminal, o ponto crítico é a thread que lê a entrada padrão:
import select
import sys
import threading
def start_input(ch: HumanChannel) -> threading.Event:
"""Lê a entrada padrão continuamente: se há pergunta pendente é resposta, senão vai para a caixa de entrada. Devolve o bit de parada."""
stop = threading.Event()
def loop() -> None:
while not stop.is_set():
if not select.select([sys.stdin], [], [], 0.2)[0]:
continue # polling, só assim o bit de parada é respeitado
line = sys.stdin.readline()
if not line: # EOF
return
raw = line.strip()
if not raw:
continue
pend = ch.pending()
if pend:
ch.answer(pend[0].id, raw) # seguro entre threads
else:
ch.send(raw) # vai para a caixa de entrada, não interrompe o trabalho em voo
threading.Thread(target=loop, daemon=True, name="stdin").start()
return stop
As três regras vieram de tropeços reais:
- Use uma thread daemon, não
asyncio.to_thread(input, ...).input()bloqueado não pode ser cancelado, e antes de sair oasyncio.runprecisa dar join nas threads do executor padrão — resultado: o trabalho termina e você ainda precisa apertar Enter mais uma vez para sair. - Use polling com
select, nãoinput()direto dentro do laço. Mesmo problema de cancelamento: uma thread bloqueada eminput()não é mais acordada porstop.set(). - Leia sempre, não só quando houver pergunta. Se você só lê quando há pergunta, tudo o que a pessoa digitou durante as horas de trabalho fica no buffer do terminal e é engolido como resposta na próxima pergunta — a pessoa nem viu a pergunta e ela já foi "respondida".
Web: fila + WebSocket¶
events: asyncio.Queue[dict] = asyncio.Queue()
def sink(ev: Event) -> None: # síncrono, na thread do event loop, não pode bloquear
try:
events.put_nowait({"kind": ev.kind, "text": ev.text,
"tool": ev.tool, "payload": ev.payload})
except Exception: # um erro no front-end não deve levar junto três horas de trabalho
pass
async def pump(ws) -> None:
while True:
await ws.send_json(await events.get())
@app.post("/answer") # thread que trata a requisição — outra thread, e isso é o normal
def answer(ask_id: str, text: str) -> dict:
return {"ok": ch.answer(ask_id, text)}
ev.raw é o objeto bruto do SDK (nos eventos ask, é um Ask); não é serializável em JSON e não deve ser enviado ao front-end — usar raw equivale a amarrar o front-end de volta aos tipos do SDK, e toda essa camada perde o sentido. Os quatro campos kind / text / tool / payload bastam.
HTTP: número de sequência + polling¶
Sem conexão persistente, numere os eventos e deixe o cliente buscar:
import itertools
from collections import deque
seq = itertools.count(1)
log: deque[dict] = deque(maxlen=2000) # guarda só os mais recentes; a memória não cresce com a duração da run
def sink(ev: Event) -> None:
log.append({"seq": next(seq), "kind": ev.kind, "text": ev.text,
"tool": ev.tool, "payload": ev.payload})
@app.get("/events") # GET /events?after=128
def events(after: int = 0) -> list[dict]:
return [e for e in log if e["seq"] > after]
@app.get("/asks") # o que está pendurado esperando resposta agora
def asks() -> list[dict]:
return [{"id": a.id, "question": a.question, "options": a.options,
"waited_s": a.waited_s} for a in ch.pending()]
@app.post("/answer")
def answer(ask_id: str, text: str) -> dict:
return {"ok": ch.answer(ask_id, text)} # False = esta pergunta já não está mais esperando
Duas fronteiras a reconhecer: quando o maxlen enche, os mais antigos são descartados, então um cliente que volta com um after muito antigo não consegue recuperar tudo — o intervalo de polling precisa ser compatível com esse tamanho; e é obrigatório dar um valor finito a timeout_s — sem ninguém fazendo polling, a pergunta nunca termina sozinha, e timeout_s=None deixa a run inteira pendurada para sempre. O padrão de 1800.0 segundos é adequado.
Totalmente automático, sem supervisão: não há ninguém¶
wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=0)
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
ctx = await wf.run(rt, on_event=None) # todos os eventos são descartados
O equivalente na linha de comando é flower "帮我做一个 X" --timeout 0.
timeout_s=0 (negativos idem) é o modo totalmente automático: a pergunta não entra na fila de espera nem emite evento asked; ela é liquidada imediatamente como state="timeout", e a ferramenta devolve este texto fixo —
— e assim a run segue normalmente. As perguntas e respostas continuam sendo anexadas a HumanChannel(log_path=...) (o starter_flow liga por padrão em <工作台>/notes/问答记录.md), então depois é possível ver o que foi perguntado e quais suposições foram adotadas.
Se você não quer que ele abra a boca, use max_asks=0: a pergunta é recusada direto (state="over_budget"), também sem bloquear. Note que isso não é "tirar a ferramenta" — allowed_tools não é exclusivo, e assim que o coordenador recebe um channel ele ganha as duas ferramentas mcp__human__ask e mcp__human__inbox juntas, estando ou não na lista. Só a cota e o timeout barram perguntas.
Sem supervisão, não deixe uma pergunta esperar para sempre
timeout_s=None significa "esperar para sempre". Sem ninguém olhando, uma única pergunta trava no lugar uma run de dez horas — sem erro, sem timeout, sem diferença visível no log. Sem supervisão existem apenas dois valores corretos: 0 (falha imediatamente) ou um número finito de segundos.
O que ele realmente faz¶
O formato do Event¶
@dataclass
class Event:
kind: EventKind # 15 valores possíveis, ver tabela abaixo
text: str = ""
tool: str = "" # só tool_call tem valor
payload: dict[str, Any] = field(default_factory=dict)
raw: Any = None # objeto bruto do SDK / Ask; tocou nele, amarrou-se de volta ao SDK
str(ev): para tool_call é [nome da ferramenta] resumo; para os demais é text; quando text está vazio, é <kind>.
Os 15 EventKind¶
kind | Quem emite | Quando aparece | text | payload |
|---|---|---|---|---|
text | normalize() | Texto do modelo | O texto | subagent, parent_tool_use_id?, context? |
thinking | normalize() | Bloco de raciocínio | Conteúdo do raciocínio | idem acima |
prompt | normalize() | Entrada: seu prompt, a task brief enviada ao subagent | Texto de entrada | idem acima |
tool_call | normalize() | O modelo inicia uma chamada de ferramenta | Resumo (file_path / command / pattern, cortado em 200 caracteres) | id, input + idem acima; tool é o nome da ferramenta |
tool_result | normalize() | Retorno da ferramenta | Primeiros 500 caracteres (vazio quando o conteúdo não é string) | tool_use_id, is_error + idem acima |
result | normalize() | Fim de uma consulta ao SDK | subtype | session_id, cost_usd, num_turns, is_error |
error | normalize() | Mensagem sintética de queda de conexão | Texto do erro | synthetic: True |
reset | normalize() | Fronteira de compact ou reset de sessão | 压缩(trigger) 167000 → 42000 tokens; em reset de sessão, conversation reset | trigger, pre_tokens, post_tokens, micro, subtype (vazio em reset de sessão) |
system | normalize() | Demais mensagens de sistema do SDK | subtype | data repassado tal como veio |
task | normalize() | Mensagem de progresso de tarefa | vazio | kind = nome da classe de mensagem do SDK |
unknown | normalize() | Tipo de mensagem não reconhecido | Nome da classe | — |
O texto de task é vazio; não o imprima direto
TaskProgressMessage e afins são tipos internos de mensagem do SDK. Antes, normalize() emitia o nome da classe como texto; na tela isso é ruído puro e, misturado ao texto do agent, parece erro (medido na prática). Agora ele vira um evento sem texto, com o nome da classe em payload["kind"] — exibir ou não é decisão da camada de interação (events.py).
| ask | HumanChannel | Precisa de resposta humana, uma pergunta chegou a um desfecho, ou uma pessoa falou por iniciativa própria | Pergunta / fala da pessoa | Duas identidades, ver abaixo | | retry | Runtime | Repetindo / pendurado esperando a rede | Uma frase de explicação | step, attempt | | step | Workflow.run | Fronteira de passo | Nome do passo | index, total, resumed, woke | | handoff | Runtime | Handoff: se aproximando / escrevendo / concluído | Uma frase com o nível de contexto | phase, step, context, window + ver abaixo |
Quatro kinds não são produzidos por normalize(): ask vem do HumanChannel, retry e handoff vêm do Runtime, e step vem de Workflow.run. Colocá-los no mesmo EventKind é proposital — a UI conhece um único conjunto de Event e não precisa abrir outro caminho para "precisa de resposta humana" ou "fronteira de passo".
Ao escrever a UI, deixe um ramo else. EventKind ainda vai ganhar novos membros, e UIs antigas não devem quebrar por causa disso.
As três phases do handoff¶
phase | Quando é emitido | Extras no payload |
|---|---|---|
near | O nível passou de warn_at. Emitido uma única vez por geração, sem inundar a tela | at (limiar de handoff) |
writing | Começou a escrever o documento de handoff. Escrever leva dezenas de segundos; sem este evento a interface parece travada | — |
done | Handoff escrito, nova sessão iniciada | degraded (se é a versão degradada), path (onde foi escrito; string vazia quando não há workbench), sections |
O mecanismo em si está em handoff.
As duas identidades do ask¶
Event("ask") carrega ao mesmo tempo "uma pergunta" e "algo que a pessoa disse por iniciativa própria"; a UI precisa olhar primeiro payload["kind"]:
| Identidade | Como reconhecer | payload |
|---|---|---|
| Uma pergunta | Não tem a chave kind | id, options, state, answer, remaining, asked_at; raw é aquele Ask |
| Fala espontânea da pessoa | payload["kind"] == "mail" | kind, state (queued ao entrar / delivered ao ser retirada), id, amended (em qual arquivo foi anexada; string vazia se não configurado). Não tem options nem remaining |
Uma pergunta emite pelo menos dois eventos: um ao ser feita (state="asked") e outro ao ter desfecho (answered / timeout / declined / over_budget / invalid). Basta a UI atualizar a mesma entrada pelo payload["id"].
Perguntar a uma pessoa: Ask e HumanChannel¶
@dataclass
class Ask:
id: str # "q1", "q2"…
question: str
options: list[str] = field(default_factory=list)
asked_at: float = field(default_factory=time.time)
state: str = "asked" # os cinco desfechos citados acima
answer: str = ""
@property
def waited_s(self) -> float: ... # segundos de espera, uma casa decimal
def event(self, remaining: int = 0) -> Event: ...
HumanChannel é um servidor MCP in-process mais um conjunto de métodos para a UI. Do lado do modelo, só duas ferramentas são visíveis: mcp__human__ask (perguntar, fica pendurado esperando) e mcp__human__inbox (consultar a caixa de entrada, não bloqueia; se estiver vazia, retorna imediatamente uma frase explicando). Construtor completo:
HumanChannel(
*, # tudo keyword-only
on_event=None, # saída em modo push. O Workflow só liga automaticamente se for None
max_asks=None, # None = sem limite; 0 = proibido perguntar. Excedeu, recusa direto, sem bloquear
timeout_s=1800.0, # None = espera para sempre; <= 0 = falha imediatamente
log_path=None, # perguntas e respostas são anexadas neste arquivo, sem ocupar contexto
amend_path=None, # o que a pessoa disser durante a run é anexado neste arquivo, normalmente o brief
over_budget_text=OVER_BUDGET, # três respostas fixas, podem ser substituídas pelas suas
timeout_text=TIMEOUT,
declined_text=DECLINED,
)
amend_path é o mais fácil de esquecer, e é ele que determina se "o requisito que a pessoa mudou no meio do caminho sobrevive à fronteira do passo". Cada passo é uma sessão nova, com artefatos congelados somente leitura: o que foi dito durante a run entrou apenas no contexto daquele agent; o passo seguinte (por exemplo, o veredito) é uma sessão totalmente nova, que lê 需求.md e 目标.md e não enxerga aquilo que você disse, então julga pelas fronteiras antigas e classifica o que foi corrigido como fora de escopo. amend_path anexa cada mensagem ao brief — anexa, não sobrescreve; o requisito original é histórico, e ver o que mudou é melhor do que não ver. O starter_flow liga por padrão em <工作台>/notes/需求.md.
Na prática ($0.6767) isso funcionou melhor do que o esperado: a pessoa disse "de passagem, reporte o total de bytes"; o coordenador consultou a caixa de entrada e reportou — "hand já leu isso no complemento em execução de .flower/notes/需求.md e já calculou, não precisa despachar de novo". O subagent leu do arquivo; não dependeu de ninguém repassar.
Membros públicos:
| Membro | Assinatura | Semântica |
|---|---|---|
tool_name | -> str | "mcp__human__ask" |
inbox_name | -> str | "mcp__human__inbox" |
mcp_servers | () -> dict | Passa direto para AgentSpec.mcp_servers. A chave precisa coincidir com o nome do servidor, por isso ele mesmo fornece o par |
ask | async (question, options=None) -> Ask | Fica pendurado esperando alguém. Nunca lança exceção, exceto CancelledError — ninguém responder também é uma resposta; distinga por ask.state |
pending | () -> list[Ask] | Perguntas atualmente penduradas esperando resposta |
next_ask | async (timeout=None) -> Ask \| None | Para UIs em modo pull. Retorna None no timeout; lança se for cancelado |
answer | (ask_id, text) -> bool | Responde. False = esta pergunta já não está mais esperando (timeout / já respondida) |
decline | (ask_id, reason="") -> bool | Pula, deixando o modelo decidir sozinho e escrever a suposição em «未知与假设» |
send | (text) -> Mail \| None | A pessoa fala por iniciativa própria, vai para a caixa de entrada. Não interrompe o agent; chama amend() internamente |
amend | (text, *, label="运行中补充") -> bool | Anexa a amend_path. Retorna se realmente escreveu (sem caminho configurado / texto vazio / OSError dão False) |
pending_mail | () -> list[Mail] | Falas ainda não retiradas |
remaining | -> int | Quantas perguntas ainda cabem. Com max_asks=None retorna -1, não 0 |
transcript | () -> str | O markdown do registro de perguntas e respostas |
asks / mail / ui_errors | list | Todas as perguntas / tudo o que a pessoa disse / exceções lançadas pelos callbacks da UI |
answer, decline e send podem ser chamados de qualquer thread. A thread de tratamento de requisições de um back-end Web e a thread de entrada de uma TUI estão em outras threads — isso é o normal, não um caso de borda. Internamente usa loop.call_soon_threadsafe, porque asyncio.Future.set_result não é thread-safe.
Push e pull: escolha um.
| Como obter | Adequado para | |
|---|---|---|
| Push | HumanChannel(on_event=…), recebendo kind == "ask" com payload["state"] == "asked" | UIs orientadas a evento (push Web, redesenho de TUI) |
| Pull | await channel.next_ask() | Uma task de entrada independente |
Os três significados de "0 / None" são todos diferentes; confundi-los resulta em travamento sem supervisão ou em nunca perguntar nada:
| Escrita | Significado |
|---|---|
max_asks=None | Sem limite de vezes (padrão) |
max_asks=0 | Proibido perguntar, recusa direto |
timeout_s=None | Espera para sempre |
timeout_s<=0 | Não espera, a pergunta falha imediatamente |
remaining retorna -1 | O valor quando max_asks=None, não 0 |
Interrupção: qualquer thread pode mandar parar¶
rt.interrupt("别改 Makefile,那两行直接改"); string vazia interrompe sem dizer nada. Três propriedades:
- Continua na mesma sessão (
resume), não recomeça do zero — o trabalho já feito e o contexto continuam lá. Reaproveita o caminho já existente de retry por queda de rede, apenas trocando o "motivo da falha" por "uma pessoa interrompeu" e oresume_promptpelo que a pessoa disse. - Não consome
max_attempts. Aquela cota é para falhas, não para pessoas. - É cooperativa: corta numa fronteira de mensagem, não cancela a task à força. O custo é o atraso até a próxima mensagem (se um subagent estiver rodando, é preciso esperá-lo voltar); o ganho é não rasgar o estado no meio do caminho.
E o custo, dito como é: a interrupção faz o subagent em voo perder o trabalho pela metade (medido naquela queda de rede em HT001, ver issue #2). A implementação de referência do terminal escreve isso no aviso, para a pessoa saber antes de apertar; a sua UI deveria fazer o mesmo.
Se você não quer interromper, apenas acrescentar um requisito, use a caixa de entrada (ch.send(...)) — ela não interrompe nada, e o atraso é até o próximo checkpoint do agent.
Oracle: perguntar algo sem atrapalhar a run¶
Para saber "onde estamos agora" não é preciso interromper, e não se deve perguntar ao coordenador: essa troca de perguntas ocupa permanentemente o contexto da main thread (que guarda decisões, não registros de perguntas e respostas) e ainda o obriga a largar o que está fazendo. Numa run de dez horas, três perguntas casuais já pagam os dois custos.
O oracle é um desvio somente leitura. Suas ferramentas são apenas Read / Glob / Grep, com o workbench aberto e freios por padrão: max_turns=12, max_budget_usd=0.5. No terminal, uma linha começando com ? o dispara, e ele responde com base em duas coisas: a janela recente de eventos (fixa em 60 itens) e o brief, o objetivo, as notas e os artefatos dentro do workbench. Ele usa um Runtime independente (<run_dir>/aside), então custo e linhagem de sessão não se misturam ao manifest principal — aquele manifesto registra "quais passos esta run executou", e uma pergunta casual não é um passo.
Na prática, duas perguntas custaram $0.5190 no total, e o manifest da run principal não cresceu um único byte.
Duas regras rígidas¶
on_event não pode bloquear nem deixar exceções escaparem
Um: on_event é uma função síncrona, chamada na thread do event loop. Portanto asyncio.Queue.put_nowait() é seguro, await não é (ela não é corrotina), e bloqueá-la é bloquear a run inteira. Se precisar fazer algo lento, jogue numa fila e deixe outra task fazer.
Dois: lançar exceção dentro de on_event machuca a própria run. Os eventos de texto são emitidos dentro do bloco try de Runtime._attempt, e a exceção é registrada como result.error — o passo é dado como falho; os eventos retry são emitidos fora desse bloco, e a exceção sobe direto por Runtime.run. O front-end não deve levar junto três horas de trabalho: envolva tudo num try seu.
Exceção: os eventos ask emitidos pelo próprio HumanChannel já estão envolvidos; as exceções vão para channel.ui_errors e não interrompem a run.
Quando não usar isto¶
O que não fazer na camada de interação¶
| Não faça | Por quê | Faça assim |
|---|---|---|
from claude_agent_sdk import ... | Se a camada de interação depender de tipos do SDK, a cada atualização do SDK o front-end muda junto, e a camada perde o sentido | Use apenas kind / text / tool / payload do Event |
Ler ev.raw | Idem acima, e além disso ele não é serializável em JSON | Faltando algum detalhe, acrescente-o ao payload em normalize(); não contorne a fronteira |
await, requisição de rede ou escrita lenta em disco dentro de on_event | Ela é síncrona e chamada na thread do event loop; bloqueá-la é bloquear a run inteira | put_nowait() numa fila e consuma em outra task |
Deixar on_event lançar exceção | A exceção em eventos de texto vira result.error e o passo é dado como falho | Envolva o corpo do callback inteiro num try |
Usar disallowed_tools para desligar as perguntas | É em nível de sessão e desabilita junto a ferramenta homônima nos subagents (erro medido, no original: "Bash is disabled for this session, in subagents as well as here") | max_asks=0 ou timeout_s=0 |
Remover mcp__human__ask de allowed_tools achando que isso proíbe perguntas | allowed_tools não é exclusivo; é uma lista de dispensa de aprovação, não uma whitelist; com um channel ligado, as duas ferramentas vão juntas | Idem acima |
Montar o caminho na mão para achar 需求.md / 目标.md | O workbench pode estar em dois lugares; errar o caminho não gera erro, apenas falha em silêncio | wake_state() ou wf.workbench |
Montar progresso e resultado a partir do fluxo de Event | O texto é quebrado em vários pedaços por handoff e retry | on_step(step, result) entrega o StepResult completo |
Usar timeout_s=None sem supervisão | Ninguém responde, a run fica pendurada para sempre, sem erro e sem timeout | 0, ou um número finito de segundos |
Quando não é preciso trocar nada¶
- Você só quer mudar as cores, imprimir uma linha a mais ou a menos — basta alterar a função de renderização. Reescrever do zero a interrupção, o oracle, os recibos da caixa de entrada, o resgate em SIGHUP / SIGTERM e a espera pelo encerramento do desvio antes de sair, tudo isso presente na implementação de referência do terminal, custa caro.
- Você só quer rodar um passo, sem interação — use
flower once. Ele não passa pelo caminho dirigido por interação, e portanto já não tem interrupção por Ctrl+C, thread de resposta pela entrada padrão, oracle nem resgate por sinal. - O que você quer trocar é o workflow, não a UI — veja projetar workflows. A camada de interação só decide quem observa e quem responde; quem decide quantos passos rodar, como julgar e quando sair mais cedo é o
Workflow. - O que você quer trocar é o session store, o modelo ou o orçamento — nenhum dos três está nesta fronteira; veja a referência da API Python.