API Python¶
Esta página esgota os 62 símbolos públicos do __all__ de topo de flower: assinaturas, parâmetros, valores padrão, semântica, atributos e métodos públicos. Depois de ler, não é preciso abrir o código-fonte para procurar parâmetros.
A organização segue aquilo com que você se importa, não os arquivos de módulo — se quer saber "como impedir que o coordenador faça o trabalho por conta própria", vá para a camada de hooks; se quer saber "como o resultado do passo anterior chega ao próximo", vá para workflow. A terminologia segue estritamente o glossário.
Versão 0.1.0, depende de claude-agent-sdk>=0.2.152. Todas as assinaturas correspondem literalmente ao código-fonte.
O que tem nesta página¶
| Assunto | Símbolos |
|---|---|
| Rodar um agent | Runtime StepResult |
| Encadear vários passos | Step Workflow StepAbort clarify_step goal_step with_goal starter_flow wake_state BRIEF_KEY MISSING_KEY CLARIFY_RESUME GOAL_KEY VERDICT_KEY ROUND_KEY |
| Construir um papel | coordinator worker clarify judge oracle COORDINATOR_RULES WORKER_RULES CLARIFIER_RULES JUDGE_RULES ORACLE_RULES |
| Escrever definições de agent à mão | AgentSpec build_options CompactPolicy HandoffPolicy default_window |
| Documentos estruturados | Brief Handoff Goal Verdict |
| Barrar ferramentas, cortar resultados, isolar | whitelist_guard delegate_guard spill_guard index_guard isolate_guard isolated wants_isolation workbench_hooks merge_hooks |
| O diretório de trabalho do spill | Workbench |
| Como e o que a sessão armazena | SqliteSessionStore TrimmingSessionStore PruningSessionStore TrimPolicy EphemeralPolicy PrunePolicy is_ephemeral trim_report |
| O que fazer quando cai a rede | Resilience classify endpoint reachable |
| Trocar a UI | Event normalize Ask HumanChannel |
| Retomar entre processos | Lineage |
Seis padrões que mordem¶
Estes seis não são detalhes marginais; são os seis acidentes mais comuns. Cada um tem explicação completa na seção correspondente.
| Padrão | Consequência | Detalhes |
|---|---|---|
Runtime(workbench=False) + coordinator() | Bash/Write/Edit da main thread ficam sem um único hook | Runtime |
Runtime(handoff=True) | Força CompactPolicy(mode="no_summary") no spec, ou seja, DISABLE_AUTO_COMPACT=1 | Runtime |
Workflow(continuous=True) | Passos com resume_from=None ainda retomam aquela sessão anterior entre processos | Workflow |
build_options(fork=True) sem resume | Falha silenciosamente, sem erro | build_options |
clarify(max_turns=<número pequeno>) | Transforma "perguntar sem limite de vezes" em conversa fiada — cada pergunta é um turno | clarify() |
AgentSpec.disallowed_tools | É a nível de sessão; proíbe também nos subagents | AgentSpec |
Runtime¶
Código-fonte: flower/core/runtime.py
Runtime é o núcleo de execução. Ele detém o workspace, o session store, a workbench, a política de resiliência e a política de handoff; para fora, tem apenas um verbo: run um passo. Retentativa, continuação após interrupção, handoff quando o contexto enche — tudo acontece dentro dessa única chamada.
Runtime¶
Runtime(
*,
workspace: str | Path,
run_dir: str | Path = "runs",
portable: bool = True,
trim: TrimPolicy | bool = False,
ephemeral: EphemeralPolicy | bool = True,
keep_denials: int = 1,
workbench: Workbench | bool = False,
spill_threshold: int | None = 4000,
resilience: Resilience | bool = True,
handoff: HandoffPolicy | bool = True,
)
Os parâmetros do construtor são todos keyword-only (* logo no início), e workspace é obrigatório.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
workspace | str \| Path | obrigatório | O cwd do agent. É resolvido na construção e recebe mkdir(parents=True, exist_ok=True). O project_key do SDK é derivado dele — se o diretório for copiado para outro lugar, o session_id antigo deixa de ser encontrável |
run_dir | str \| Path | "runs" | Guarda sessions.db, manifest.json, lineage.json e, quando workbench=True, a workbench padrão. Também é resolvido e criado com mkdir |
portable | bool | True | Repassado para build_options(portable=), ou seja, setting_sources=[]: não lê o ~/.claude/ da máquina hospedeira, nem o .claude/ do projeto. Ver portable |
trim | TrimPolicy \| bool | False | Se receber uma instância, usa direto; se receber bool, vira TrimPolicy(enabled=bool(trim)). Desligar só significa não fazer trim de resultados grandes; o prune continua acontecendo |
ephemeral | EphemeralPolicy \| bool | True | Mesma regra de conversão. Forma par com coordinator(glance=True) — se você libera a main thread para rodar git status, precisa garantir que aquele resultado expire |
keep_denials | int | 1 | Passado para PrunePolicy(keep_denials=). Mantém as N chamadas de ferramenta negadas mais recentes; as mais antigas são removidas junto com chamada e resultado |
workbench | Workbench \| bool | False | Se receber uma instância, usa direto; se receber True, cria Workbench(workspace, home=run_dir / "workbench") (por padrão fica fora do workspace). Em seguida chama refresh() imediatamente |
spill_threshold | int \| None | 4000 | A partir de quantos caracteres um resultado de ferramenta vai para spill. None ou 0 = não instala o spill_guard |
resilience | Resilience \| bool | True | Mesma regra de conversão |
handoff | HandoffPolicy \| bool | True | Mesma regra de conversão |
O session store é fixo no código: sempre PruningSessionStore(run_dir/"sessions.db", workspace=..., policy=<TrimPolicy>, ephemeral=<EphemeralPolicy>, prune=PrunePolicy(keep_denials=...)). Os parâmetros do construtor não oferecem ponto de entrada para trocar de backend — para trocar, construa seu próprio AgentSpec + build_options(session_store=...), ou sobrescreva rt.store depois de construir.
Os dois últimos passos da construção são load_dotenv() e check_credentials(), e este último faz raise RuntimeError em caso de erro. Sem credenciais, a explosão acontece já na construção, não ao chegar em run().
workbench=False + coordinator() = a main thread fica sem nenhuma barreira
O delegate_guard só é instalado dentro de workbench_hooks, e workbench_hooks só é chamado quando self.workbench is not None; o whitelist_guard, por sua vez, é pulado por if not spec.delegate_only. E coordinator() sempre define delegate_only=True e, por padrão, glance=True dá acesso a Bash.
Conclusão: com um coordenador em Runtime(workbench=False), seus Bash/Write/Edit não são barrados por hook nenhum. Ao usar coordinator(), ligue a workbench — Runtime(..., workbench=True) ou passe uma instância de Workbench.
handoff=True (padrão) força o desligamento do auto-compact
Em _attempt: handoff.enabled and spec.compact is None → spec = replace(spec, compact=CompactPolicy(mode="no_summary")), o que no subprocesso vira DISABLE_AUTO_COMPACT=1. A razão é que, com os dois mecanismos ligados ao mesmo tempo, não dá para dizer quem causou a queda do contexto.
Custo: o passo que escreve o handoff precisa ter um caminho degradado (handoff.degraded), porque não há mais compact como rede de segurança. Para manter o auto-compact, defina AgentSpec.compact explicitamente (se o spec já traz o seu, ele é respeitado e não sobrescrito).
Atributos públicos¶
| Atributo | Tipo | Descrição |
|---|---|---|
workspace | Path | Workspace após resolve |
run_dir | Path | Diretório de run após resolve |
portable | bool | Guardado como veio |
store | PruningSessionStore | Session store. Para trocar de backend só sobrescrevendo após a construção |
resilience | Resilience | Instância normalizada |
handoff | HandoffPolicy | Instância normalizada |
workbench | Workbench \| None | É None quando workbench=False |
spill_threshold | int \| None | Guardado como veio; em _attempt é passado para workbench_hooks |
results | list[StepResult] | Cada passo executado neste processo, anexado em ordem |
run_id | str | "%Y%m%d-%H%M%S" + "-" + uuid4().hex[:6]. Precisa ser único por instância — o manifest.json deduplica pelo campo run, e quando dois ids colidem, o que escreve depois apaga as linhas do outro achando que são as suas da vez anterior |
on_session | Callable[[str], None] \| None | Callback disparado imediatamente ao obter um novo session_id; padrão None. Só deve envolver a linha runtime.run — o judge usa o mesmo Runtime, e deixá-lo pendurado durante o gate escreve a sessão do judge na linhagem do passo que fez o trabalho |
Constantes de classe: INTERRUPTED = "interrupted-by-human", HANDOFF_DUE = "context-full-handoff", INTERRUPT_NOTE (o trecho anexado após a fala da pessoa ao continuar depois de uma interrupção, explicando que "chamadas de ferramenta em voo retornando interrupted são efeito colateral normal da interrupção, não falha de ambiente").
Métodos públicos¶
| Método | Assinatura | Descrição |
|---|---|---|
run | async (spec, prompt, *, step_name=None, resume=None, fork=False, resume_at=None, on_event=None) -> StepResult | Roda um passo. Ver abaixo |
interrupt | (message: str = "") -> None | Solicita interrupção do turno atual. Pode ser chamado de qualquer thread. Cooperativo: desconecta limpo na fronteira de mensagem, sem cancelamento forçado. String vazia = interromper sem dizer nada |
rescue | () -> None | Antes de ser morto à força, tenta registrar as contas por completo; chamado pelos handlers de SIGHUP/SIGTERM. O passo em voo também é escrito no manifest, com error="killed-by-signal". Faz apenas escritas síncronas pequenas |
manifest_path | @property -> Path | run_dir / "manifest.json" |
project_key | @property -> str | Em str(workspace.resolve()), /, _ e . são todos trocados por -. O SDK deriva isso do cwd; quem chama não pode especificar |
has_session | (session_id: str) -> bool | Este id ainda é encontrável sob este workspace? Síncrono, não lê payload |
context_of | (session_id: str) -> int | Tamanho de contexto do último turno de uma sessão; delega para store.last_context |
total_cost | () -> float | round(sum(r.cost_usd for r in self.results), 4) |
close | () -> None | self.store.close() |
Runtime.run(...)¶
async def run(
self,
spec: AgentSpec,
prompt: str,
*,
step_name: str | None = None,
resume: str | None = None,
fork: bool = False,
resume_at: str | None = None,
on_event: Callable[[Event], None] | None = None,
) -> StepResult
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
spec | AgentSpec | obrigatório, posicional | Declaração do agent a rodar |
prompt | str | obrigatório, posicional | O que é dito neste turno |
step_name | str \| None | None | Chave que aparece em StepResult.step, no manifest e na linhagem. None → spec.name |
resume | str \| None | None | Continua a partir deste session_id |
fork | bool | False | Bifurca para uma nova sessão, sem poluir a original. Só tem efeito quando resume é verdadeiro |
resume_at | str \| None | None | Continua a partir de uma mensagem específica (rollback). Também só tem efeito quando resume é verdadeiro |
on_event | Callable[[Event], None] \| None | None | Saída de eventos, ver Event |
No início de cada passo, o nível de contexto é zerado (self._ctx, self._warned = 0, False). Depois vem um laço com quatro saídas:
- Sucesso → sai do laço.
- Interrupção humana (
result.error == INTERRUPTED) → não está sujeita amax_attempts, não espera a rede. Fazresumena mesma sessão levando a fala da pessoa,attempt -= 1(interrupção não conta como tentativa falha), prompt = fala da pessoa +INTERRUPT_NOTE. Se não obtevesession_id, só resta parar. - Contexto cheio (
result.error == HANDOFF_DUE, ouhandoff.enabledcomsession_idobtido eis_overflow(...)acionado) → também não está sujeito amax_attempts. Primeiro verificalen(result.retired) >= handoff.max_generations; se passou, troca o error por uma linha de diagnóstico e sai do laço; caso contrário escreve o documento de handoff →resume=None, fork=False(sessão totalmente nova) → prompt virah.prompt_block()→ nível zerado →attempt -= 1. - Falha retentável → sai se
not resilience.enabled or attempt >= max_attempts; sai também seclassify(error)decidir que não se deve retentar; caso contrário emiteEvent("retry"), fica pendurado emwait_online()esperando a rede,sleep(delay_for(attempt)); se algumsession_idjá foi obtido, continua comresume(o prompt viraresilience.resume_prompt) e defineresult.resumedcomoTrue.
Encerramento: escreve ended_at, anexa a self.results, escreve o manifest.json.
O manifest.json tem semântica de append: cada escrita relê o disco e deduplica pelo campo run (a própria linha é substituída, as dos outros permanecem), então rodar dois flowers em paralelo no mesmo run_dir é seguro — desde que os run_id não colidam.
Os três pontos de observação do handoff (todos Event("handoff"), diferenciados por payload["phase"]): near (aproximando-se de warn_at, emitido uma vez por geração), writing (escrevendo o handoff, leva dezenas de segundos), done (payload traz degraded / path / sections). O turno que escreve o handoff roda com replace(spec, max_budget_usd=None) — o handoff precisa conseguir ser escrito, não pode travar no orçamento; além disso on_event=None, esse turno não atualiza a UI.
O handoff é gravado em <workbench.notes>/交接-<步骤名>.md; sem workbench não há gravação, o documento ainda é entregue a quem assume via prompt, apenas não fica consultável depois. Handoffs antigos são movidos para notes/archive/交接/<名>-<时间戳>.md.
StepResult¶
@dataclass
class StepResult:
step: str
session_id: str | None = None
ok: bool = False
cost_usd: float = 0.0
num_turns: int = 0
text: str = ""
error: str | None = None
started_at: float = 0.0
ended_at: float = 0.0
attempts: int = 1
errors: list[str] = field(default_factory=list)
resumed: bool = False
retired: list[str] = field(default_factory=list)
context: int = 0
Toda a contabilidade de um passo concluído.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
step | str | obrigatório | Nome do passo (step_name ou spec.name) |
session_id | str \| None | None | É sempre a última sessão que assumiu — as queimadas em handoffs no meio do caminho estão em retired |
ok | bool | False | Se este passo deu certo |
cost_usd | float | 0.0 | Em dólares. Nas retentativas e handoffs é acumulado |
num_turns | int | 0 | Número de turnos, também acumulado |
text | str | "" | Contém apenas o texto da main thread. As falas dos subagents ficam no transcript deles, e o task brief entregue a eles é kind="prompt"; nenhum dos dois entra aqui |
error | str \| None | None | Motivo da falha. Valores especiais em Runtime.INTERRUPTED / Runtime.HANDOFF_DUE |
started_at / ended_at | float | 0.0 | Timestamps Unix |
attempts | int | 1 | Número real de tentativas. Interrupções e handoffs não contam |
errors | list[str] | [] | Mensagens de erro sintéticas da API coletadas; não entram em text |
resumed | bool | False | Se houve resume no meio do caminho |
retired | list[str] | [] | Os session_id queimados por handoff neste passo, em ordem |
context | int | 0 | Tamanho de contexto realmente visto pela main thread no último turno, ou seja, o critério de handoff |
| Atributo | Tipo | Descrição |
|---|---|---|
duration_s | @property -> float | round(ended_at - started_at, 2); é 0.0 se não terminou |
Workflow¶
Código-fonte: flower/workflow/
Um workflow é um conjunto de steps encadeados em ordem, mais como o estado passa de um step para o outro e quando se sai mais cedo. O framework não fornece workflows prontos; o workflow é você que escreve — starter_flow é apenas um modelo que roda.
Alias de tipo Ctx = dict[str, Any] (flower.workflow.base.Ctx, está em flower.workflow.__all__, não no __all__ de topo).
Step¶
@dataclass
class Step:
name: str
spec: AgentSpec
prompt: str | Callable[[Ctx], str]
resume_from: str | None = None
fork: bool = False
retries: int = 0
gate: Callable[[StepResult, Ctx], bool] | None = None
on_fail: str = "stop"
when: Callable[[Ctx], bool] | None = None
on_reject: Callable[[StepResult, Ctx], str] | None = None
resume_prompt: str | Callable[[Ctx], str] | None = None
reduce: Callable[[StepResult, Ctx], str] | None = None
A declaração de um step. Step em si não é uma função — quem executa de fato é Runtime.run(step.spec, prompt, ...). Os três primeiros campos são argumentos posicionais; Step("取词", terse, "读 seed.txt …") é uma escrita válida.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | obrigatório | Nome do step. Chave estável entre processos — cai em ctx[name], ctx["_results"], no manifest e na lineage. Renomear = romper a lineage |
spec | AgentSpec | obrigatório | Qual agent rodar |
prompt | str \| Callable[[Ctx], str] | obrigatório | O que dizer. Pode ser um closure que calcula na hora a partir de ctx |
resume_from | str \| None | None | De qual step retomar a sessão. Se o step apontado não produziu sessão, lança ValueError, não pula em silêncio |
fork | bool | False | Faz fork sobre a base de resume_from. Sem resume_from não tem efeito |
retries | int | 0 | Quantas tentativas extras quando o gate não passa. retries=0 = uma única rodada |
gate | Callable[[StepResult, Ctx], bool] \| None | None | Decide se esta tentativa passou. Pode ser async. Retornar False conta como falha. É chamado uma única vez por tentativa — ele pode ter efeitos colaterais (por exemplo, gravar o brief em disco) e não deve ser disparado repetidamente |
on_fail | str | "stop" | "stop" / "skip" / "continue", ver abaixo |
when | Callable[[Ctx], bool] \| None | None | Retornar False pula o step inteiro: não gera result, não entra em ctx["_results"]. Pode ser async |
on_reject | Callable[[StepResult, Ctx], str] \| None | None | O que dizer na próxima rodada quando o gate não passa. Pode ser async. Fornecê-lo muda a semântica de retry, ver abaixo |
resume_prompt | str \| Callable[[Ctx], str] \| None | None | Prompt usado quando há continuidade (em vez de recomeçar do zero) |
reduce | Callable[[StepResult, Ctx], str] \| None | None | Decide o que vai em ctx[name]. Por padrão, o texto original de result.text. Tem de ser uma função síncrona |
| Método | Assinatura | Descrição |
|---|---|---|
render | (ctx: Ctx, *, resuming: bool = False) -> str | Se resuming e houver resume_prompt, usa este último; caso contrário usa prompt; se for chamável, passa ctx ao invocá-lo |
Três formas de conectar sessões (dentro de uma mesma run):
| Escrita | Efeito |
|---|---|
resume_from=None (padrão) | Sessão nova, contando só com o contexto passado no prompt. Barato, isolado. Mas com Workflow(continuous=True) ele pega a sessão do step de mesmo nome na lineage entre processos |
resume_from="nome do step anterior" | Continua na mesma sessão, contexto completo. Caro, coerente |
resume_from="nome do step anterior", fork=True | Faz fork, sem poluir a sessão original. Para revisão / múltiplas alternativas em paralelo |
on_reject muda a semântica do retry:
- Não fornecido → a próxima tentativa recomeça do zero (mesmo prompt, mesmo
resume_from). - Fornecido → a próxima tentativa continua a sessão que acabou de ser rejeitada, com o prompt trocado pelo valor de retorno dele, e
forkforçado paraFalse. - Retornar string vazia → não devolve nada; degrada para recomeçar do zero.
result.session_idsendoNone→ também degrada para recomeçar do zero.
Os três valores de on_fail:
| Valor | Comportamento |
|---|---|
"stop" (padrão) | Escreve ctx["_failed_at"] = name e interrompe o workflow inteiro |
"skip" | Vai para o próximo step, ctx[name] não é escrito — um lambda ctx: ctx["某步"] a jusante vai dar KeyError |
"continue" | ctx[name] = result.text, segue em frente com um resultado incompleto |
Passando ou não, ctx["_results"][name] = result sempre é escrito; quando result.session_id não é vazio, também é escrito em ctx["_sessions"] e chamado lineage.remember(...).
Workflow¶
@dataclass
class Workflow:
steps: list[Step]
name: str = "workflow"
context: Ctx = field(default_factory=dict)
channel: Any = None
workbench: Any = None
continuous: bool = True
async def run(
self,
runtime: Runtime,
*,
on_event: Callable[[Event], None] | None = None,
on_step: Callable[[Step, StepResult], None] | None = None,
) -> Ctx
Roda uma sequência de Step em ordem e retorna o ctx final. steps é argumento posicional, então Workflow([...]) é válido.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
steps | list[Step] | obrigatório | Executados em ordem |
name | str | "workflow" | Nome do workflow |
context | Ctx | {} | Dicionário de contexto inicial. Ao rodar o mesmo Workflow uma segunda vez, o ctx é o mesmo dict |
channel | HumanChannel \| None | None | Onde pendurar o canal quando for preciso parar e perguntar a alguém. run() liga automaticamente o on_event dele à mesma saída, apenas quando channel.on_event is None; o driver também usa este campo para saber a quem responder |
workbench | Workbench \| None | None | Workbench indicado pelo workflow, para que o driver consiga encontrá-lo |
continuous | bool | True | Mesmo caminho = mesma conversa. A implementação é Lineage |
Parâmetro de run() | Tipo | Padrão | Descrição |
|---|---|---|---|
runtime | Runtime | obrigatório, posicional | Com qual runtime rodar |
on_event | Callable[[Event], None] \| None | None | Saída de eventos, repassada a cada Runtime.run |
on_step | Callable[[Step, StepResult], None] \| None | None | Callback uma vez ao fim de cada step |
continuous=True é o padrão; resume_from=None não significa sessão nova
Com continuidade ligada, run() primeiro chama Lineage.open(run_dir, workspace) e depois valida registro por registro com runtime.has_session(sid) se ainda estão no banco; só os vivos são injetados em ctx["_sessions"]. Assim, até os steps com resume_from=None continuam falando na sessão da vez anterior — mesmo com o processo morto ou a máquina reiniciada.
Para ter sessão nova toda vez, escreva explicitamente Workflow(..., continuous=False). Além disso: o nome do step é a chave estável entre processos; mudar o nome do step equivale a romper a lineage.
As chaves privadas que run() escreve no ctx (todas começam com _, não colidem com nomes de step):
| Chave | Conteúdo |
|---|---|
_runtime | O Runtime passado. É por ele que um gate despacha agents |
_on_event | Saída de eventos. O agent dentro do gate também precisa chegar à UI, senão a interface fica no escuro |
_sessions | dict[nome do step, session_id], lido com setdefault |
_results | dict[nome do step, StepResult] |
_lineage | Objeto Lineage. Só existe quando continuous=True e o runtime tem run_dir + workspace |
_woke | Valor de retorno de lineage.bump(), qual é este wake |
_aborted | Mensagem do StepAbort |
_failed_at | Nome do step que falhou com on_fail="stop" |
Payload de Event("step"): {"index": i, "total": len(steps), "resumed": bool, "woke": int}.
Rótulos de retry: a tentativa 0 usa step.name; depois disso, com on_reject usa f"{name}#round{attempt+1}", sem ele usa f"{name}#retry{attempt}". No manifest dá para ver de relance como aquele step chegou ao fim. Nomes com sufixo não entram na lineage entre processos — Lineage.remember usa o nome original.
runtime.on_session cobre apenas a linha runtime.run, com try/finally garantindo que seja removido antes do gate. prompt_cur / resume_cur / fork_cur são variáveis locais e não são escritas de volta no step — o mesmo objeto Step pode ser rodado uma segunda vez.
StepAbort¶
Lançada pelo gate = pare já, não tente de novo. A diferença para "retornar False": False é "desta vez não deu, mais uma rodada"; StepAbort é "tentar de novo não adianta".
Depois de lançada: ctx["_aborted"] = str(exc), passed = False, sai do laço de retry (sem consumir os retries restantes) e então segue o on_fail como uma falha comum (padrão "stop").
with_goal a lança em dois pontos: quando não consegue obter ctx["_runtime"], e quando o verdict é unreachable e ninguém responde.
clarify_step()¶
def clarify_step(
channel: HumanChannel,
*,
brief_path: str | Path,
prompt: str | Callable[[Ctx], str],
name: str = "确认需求",
spec: AgentSpec | None = None,
instructions: str = "",
always_ask: bool = False,
on_fail: str = "stop",
retries: int = 0,
**spec_kw,
) -> Step
Produz um Step que faz o clarify: esclarecer a demanda → parsear para Brief → congelar e gravar em disco quando as quatro seções estiverem completas.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
channel | HumanChannel | obrigatório, posicional | Canal de perguntas |
brief_path | str \| Path | obrigatório | Onde o brief é gravado. Tem de cair no workbench que é de fato injetado no índice |
prompt | str \| Callable[[Ctx], str] | obrigatório | A demanda original da pessoa |
name | str | "确认需求" | Nome do step, e ao mesmo tempo a chave no ctx |
spec | AgentSpec \| None | None | Se não for dado, usa clarify(name, channel, instructions=instructions, **spec_kw) |
instructions | str | "" | Instruções complementares anexadas ao clarifier |
always_ask | bool | False | True = pergunta tudo de novo toda vez, exista ou não o brief |
on_fail | str | "stop" | Igual a Step.on_fail |
retries | int | 0 | Quantas vezes perguntar de novo quando as quatro seções não se completam |
**spec_kw | Repassado direto para clarify(), então dá para escrever can_read=False, max_budget_usd=... |
No Step produzido, os campos ficam assim:
resume_prompt = CLARIFY_RESUME.when:always_ask=True→ sempreTrue; caso contrário, seBrief.load(brief_path)estiver completo, injeta-o no ctx e então retornaFalse(pula) — mesmo pulando é preciso injetar, senão quem vem depois não recebe a demanda.gate:Brief.parse(result.text); incompleto → escrevectx[MISSING_KEY]e retornaFalse; completo →b.write(brief_path)para congelar, injeta no ctx, retornaTrue.reduce: retornactx[BRIEF_KEY].prompt_block(), não o texto original do modelo — no texto original pode vir junto coisa que ele escreveu a mais.resume_fromfica no padrãoNone: o próximo step é uma sessão nova, que recebe só o brief e não a rodada de perguntas e respostas. As perguntas do clarify nunca entraram no contexto do coordinator; não é que entraram e foram podadas depois.
Os três pontos injetados no ctx: ctx[BRIEF_KEY] = b, ctx[name] = b.prompt_block(), ctx.pop(MISSING_KEY, None).
| Constante | Valor | Descrição |
|---|---|---|
BRIEF_KEY | "_brief" | ctx[BRIEF_KEY] é o objeto Brief; ctx[step.name] é o prompt_block() dele |
MISSING_KEY | "_brief_missing" | Quais seções faltam quando o clarify falha (nomes das seções em chinês), para exibir na UI |
CLARIFY_RESUME | um prompt em chinês | «continue a confirmação de requisitos que ficou incompleta — não recomece…». Sem essa frase, a continuidade reenvia a demanda original como se fosse tarefa nova, e o clarifier pode repetir perguntas já feitas |
goal_step()¶
def goal_step(
channel: HumanChannel,
*,
goal_path: str | Path,
brief_key: str = "确认需求",
name: str = "设定目标",
spec: AgentSpec | None = None,
instructions: str = "",
always_set: bool = False,
on_fail: str = "stop",
retries: int = 0,
**spec_kw: Any,
) -> Step
Produz um Step que define o objetivo: faz o judge ler o brief, escrever o objetivo + o checklist de verdict, e depois de parsear para Goal congelar e gravar em disco. Tem o mesmo formato de clarify_step.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
channel | HumanChannel | obrigatório, posicional | Canal de perguntas |
goal_path | str \| Path | obrigatório | Onde o arquivo de objetivo é gravado |
brief_key | str | "确认需求" | Pega o texto do brief em ctx[brief_key] para colocar no prompt. Se não achar, vira "(没有确认书)" |
name | str | "设定目标" | Nome do step |
spec | AgentSpec \| None | None | Se não for dado, usa judge(name, channel, instructions=instructions, **spec_kw) |
instructions | str | "" | Instruções anexadas |
always_set | bool | False | True = refaz o checklist, exista ou não o arquivo de objetivo |
on_fail | str | "stop" | Como acima |
retries | int | 0 | Como acima |
**spec_kw | Repassado para judge() |
Não existe o parâmetro can_run — para que o judge que define o objetivo possa rodar comandos, só passando can_run=True via **spec_kw. Sem isso ele não recebe Bash, e a regra "primeiro veja bem em que ambiente você está" de JUDGE_RULES não pode ser executada.
Além de parsear e congelar, o gate faz mais uma coisa: quando há itens [此环境无法验证:…] no objetivo, ele emite na hora, via ctx["_on_event"], um Event("task", payload={"unverifiable", "total", "path"}) como aviso — o destino desses itens já está selado no momento em que o objetivo é definido, e quando chega a hora do verdict o dinheiro de uma rodada inteira de trabalho já foi gasto.
Não define resume_prompt — definir o objetivo deve mesmo reenviar o brief por inteiro.
| Constante | Valor | Descrição |
|---|---|---|
GOAL_KEY | "_goal" | ctx[GOAL_KEY] é o objeto Goal; ctx[step.name] é o markdown |
VERDICT_KEY | "_verdict" | O Verdict mais recente, para uso da UI |
ROUND_KEY | "_goal_rounds" | Quantas rodadas de verdict já rodaram |
with_goal()¶
def with_goal(
step: Step,
channel: HumanChannel,
*,
goal_path: str | Path,
spec: AgentSpec | None = None,
rounds: int = 3,
instructions: str = "",
can_run: bool = False,
name: str | None = None,
**spec_kw: Any,
) -> Step
Envolve um Step existente com o goal guard: ao fim de cada rodada o judge emite um verdict independente; se o objetivo não foi alcançado, devolve para continuar trabalhando.
O resultado é replace(step, retries=max(0, rounds - 1), gate=<novo gate>, on_reject=<novo on_reject>) — usa dataclasses.replace em vez de reconstruir campo a campo; uma reconstrução já deixou o resume_prompt escapar, e sem erro nenhum, apenas reenviando o brief inteiro de novo na continuidade.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
step | Step | obrigatório, posicional | O step a ser guardado |
channel | HumanChannel | obrigatório, posicional | Canal para pedir ajuda a uma pessoa quando não dá para decidir |
goal_path | str \| Path | obrigatório | Arquivo de objetivo, lido daqui quando ctx[GOAL_KEY] não está completo |
spec | AgentSpec \| None | None | Se não for dado, usa judge(label, channel, instructions=..., can_run=can_run, **spec_kw) |
rounds | int | 3 | Total de rodadas, não rodadas extras: rounds=3 → retries=2 → no máximo três rodadas de trabalho. rounds=1 = uma rodada, um verdict, e falha se não passar |
instructions | str | "" | Instruções anexadas ao judge |
can_run | bool | False | Se o judge pode rodar Bash |
name | str \| None | None | Nome do judge, por padrão f"{step.name}·判定" |
**spec_kw | Repassado para judge() |
O gate é async, e o fluxo é:
ctx["_runtime"]ausente → lançaStepAbort("拿不到 Runtime,无法判定目标"). Não finja que passou.ctx[ROUND_KEY] += 1.- Pega o objetivo: primeiro um
Goalcompleto emctx[GOAL_KEY], senãoGoal.load(goal_path), senão umGoal()vazio. await rt.run(judger, VERIFY_PROMPT..., step_name=f"{label}#{轮次}", on_event=...). O judge é umRuntime.runindependente, comresumesempreNone— é sempre uma sessão nova; ostep_namecarrega o número da rodada, então não entra na lineage entre processos.Verdict.parse(vr.text)é escrito emctx[VERDICT_KEY].v.achieved→ retornaTrue.- Não sendo
unreachable(inclusive os casos ambíguos comv.ok=False) → nos casos ambíguos acrescenta um reason padrão e retornaFalse. Ambiguidade é sempre tratada como não alcançado — não dá para deixar um "parece que dá" encerrar o trabalho. unreachable→await channel.ask(...)pergunta a uma pessoa, com três opções:- Ninguém responde (
a.state != "answered") → lançaStepAbort. Continuar girando em falso é a escolha mais cara. - 「接受这个结果,就这样往下走」 → retorna
True. - 「修改目标」 → pergunta de novo o novo objetivo, faz
g.amend(...).write(goal_path), atualizactx[GOAL_KEY]e retornaFalse. - Qualquer outra coisa (inclusive resposta livre digitada pela pessoa) → é tratada como "você julgou errado", registra a fala da pessoa em
v.reasone retornaFalse.
O on_reject é síncrono: retorna ctx[VERDICT_KEY].feedback(); sem Verdict, retorna "" (degrada para recomeçar do zero).
starter_flow()¶
def starter_flow(
ask: str,
*,
workspace: str | Path = ".",
run_dir: str | Path = "runs",
new: bool = False,
isolate: bool = False,
clarify_only: bool = False,
goal: bool = True,
rounds: int = 3,
judge_can_run: bool = False,
max_asks: int | None = None,
timeout_s: float | None = 1800.0,
instructions: str = "",
worker_prompt: str = "你负责实现。每改一处就跑一次验证,别攒到最后。",
brief_name: str = "需求.md",
goal_name: str = "目标.md",
log_name: str = "问答记录.md",
) -> Workflow
Monta um workflow de três steps que roda de imediato: confirmar requisitos → definir objetivo → trabalhar (com goal guard). É o que a linha de comando flower usa.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
ask | str | obrigatório, posicional | Uma frase de demanda. No wake ela não é uma tarefa nova, é "mais uma coisa dita" |
workspace | str \| Path | "." | Workspace |
run_dir | str \| Path | "runs" | Diretório da run |
new | bool | False | True = arquiva lineage + brief + objetivo (os três juntos) e começa do zero |
isolate | bool | False | Abre um worktree de isolamento para o worker. O workbench passa junto para <ws>.parent/.flower-<ws.name> |
clarify_only | bool | False | Retorna apenas o Workflow com o step de clarify |
goal | bool | True | Instalar ou não o goal guard. False = acabou o step de trabalho, acabou tudo |
rounds | int | 3 | Repassado a with_goal(rounds=), total de rodadas |
judge_can_run | bool | False | Repassado a with_goal(can_run=) |
max_asks | int \| None | None | Repassado ao HumanChannel, None = sem limite |
timeout_s | float \| None | 1800.0 | Repassado ao HumanChannel. 0 = totalmente automático, toda pergunta cai no vazio na hora |
instructions | str | "" | Instruções anexadas ao clarifier |
worker_prompt | str | ver assinatura | System prompt do worker |
brief_name | str | "需求.md" | Nome do arquivo do brief, gravado em <workbench.notes>/ |
goal_name | str | "目标.md" | Nome do arquivo de objetivo, idem |
log_name | str | "问答记录.md" | Nome do arquivo de registro de perguntas e respostas, idem |
Montagem fixa:
Workflow(name="starter", channel=ch, workbench=wb, steps=[...])
# ch = HumanChannel(log_path=<notes>/问答记录.md, amend_path=<brief_path>,
# max_asks=max_asks, timeout_s=timeout_s)
# 协调者 = coordinator("协调者", "", {"coder": worker(..., isolate=isolate)}, channel=ch)
Ramificações de comportamento:
isolate=Truecom o workspace não sendo um repositório git → lançaValueError, em vez de só descobrir quando a ferramentaAgentreclama (aí o dinheiro já foi gasto).- Detecção de wake: se
Brief.load(brief_path)existe e estácomplete(), é um wake. Se não é wake easkestá vazio → lançaValueError("要给一句诉求,例如 flower '帮我做一个 X'"). - No wake, essa frase cai em três lugares ao mesmo tempo; faltar um deles causa falha silenciosa: anexada ao brief (
ch.amend(said, label="唤醒时追加"), sem reescrever se já estiver no arquivo); fazendogoal_step(always_set=True)refazer o checklist (sem isso, o judge continua lendo o objetivo antigo); e entregue direto ao coordinator (no contexto dele está o objetivo antigo, e sem isso ele trabalha pelo critério antigo e é julgado pelo critério novo).
wake_state()¶
def wake_state(
workspace: str | Path = ".",
*,
run_dir: str | Path = "runs",
isolate: bool = False,
brief_name: str = "需求.md",
goal_name: str = "目标.md",
) -> dict
Uma sondagem somente-leitura antes da largada, que não escreve um único byte. Serve para dizer à pessoa, antes de começar de verdade, "isto é continuação da vez passada ou começo do zero".
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
workspace | str \| Path | "." | Workspace, argumento posicional |
run_dir | str \| Path | "runs" | Diretório da run |
isolate | bool | False | Determina a posição do workbench; tem de ser o mesmo valor passado a starter_flow |
brief_name | str | "需求.md" | Nome do arquivo do brief |
goal_name | str | "目标.md" | Nome do arquivo de objetivo |
O dict retornado:
| Chave | Tipo | Descrição |
|---|---|---|
waking | bool | O brief existe e as quatro seções estão completas |
brief | Path | <workbench.notes>/需求.md |
goal | Path | <workbench.notes>/目标.md |
checks | int | Número de itens do checklist do objetivo; 0 quando não há objetivo |
woke | int | Lineage.woke, quantos wakes já houve |
steps | dict | Cópia de Lineage.steps, nome do step → session_id |
A posição do workbench é definida uma única vez, aqui e em starter_flow: isolate=True → <ws>.parent/.flower-<ws.name> (fora do repositório); caso contrário <ws>/.flower. Um driver que queira saber onde está o brief também passa por esta função — montar o caminho por conta própria e errar não dá erro, apenas falha em silêncio.
Fábrica de papéis¶
Código-fonte: flower/core/roles.py
Os cinco papéis são funções de fábrica. Cada papel = um trecho de texto de regras injetado + um conjunto de ferramentas + um conjunto de hooks. worker() produz o AgentDefinition do SDK (para ser despachado a um subagent); os outros quatro produzem AgentSpec (abrem a própria sessão).
Os papéis em si não montam hooks — interceptar ferramentas é trabalho que Runtime._attempt instala automaticamente conforme spec.delegate_only, veja camada de hooks.
Constantes internas de grupos de ferramentas (não exportadas, mas determinam os padrões):
COORDINATOR_TOOLS = ["Agent", "TodoWrite", "Read"]
WEB_TOOLS = ["WebFetch", "WebSearch"]
WORKER_TOOLS = ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebFetch", "WebSearch"]
coordinator()¶
def coordinator(
name: str,
instructions: str,
workers: dict[str, AgentDefinition],
*,
channel: Any = None,
can_read: bool = True,
glance: bool = True,
model: str | None = None,
effort: str | None = None,
max_turns: int | None = None,
max_budget_usd: float | None = None,
permission_mode: str = "acceptEdits",
compact: Any = None,
hooks: dict[str, Any] | None = None,
env: dict[str, str] | None = None,
) -> AgentSpec
Constrói o coordenador que fica na thread principal: decompõe a tarefa, despacha trabalho, lê relatórios, decide, mas não põe a mão na massa. Os três primeiros parâmetros são posicionais.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | obrigatório | Nome do papel, e também o nome padrão do step |
instructions | str | obrigatório | Instruções de domínio. No final vira f"{COORDINATOR_RULES}\n{instructions}".strip() |
workers | dict[str, AgentDefinition] | obrigatório | Quais papéis estão sob seu comando; cai em AgentSpec.agents. As ferramentas web somente-leitura deles também são mescladas no próprio allowed_tools do coordenador, veja abaixo |
channel | HumanChannel \| None | None | Se fornecido, acrescenta ao mesmo tempo as ferramentas inbox e ask, e define mcp_servers |
can_read | bool | True | True → ["Agent", "TodoWrite", "Read"]; False → remove Read |
glance | bool | True | Acrescenta "Bash" e define AgentSpec.glance. O que exatamente pode ser executado quem decide é o delegate_guard, não isto aqui |
model | str \| None | None | Modelo |
effort | str \| None | None | Intensidade de raciocínio |
max_turns | int \| None | None | Limite de turnos |
max_budget_usd | float \| None | None | Teto de orçamento |
permission_mode | str | "acceptEdits" | Modo de permissão. Atenção a este padrão — passá-lo para clarify()/judge() desmonta a proteção desses dois papéis |
compact | CompactPolicy \| None | None | Se fornecido, não será forçado pelo Runtime para no_summary |
hooks | dict[str, Any] \| None | None | Hooks extras, mesclados com workbench_hooks |
env | dict[str, str] \| None | None | Variáveis de ambiente extras |
Três itens fixos no AgentSpec produzido: delegate_only=True, agents=workers, e workbench mantém o padrão True do AgentSpec.
As ferramentas web dos workers são mescladas aqui¶
Depois de montar a lista, coordinator() percorre cada AgentDefinition.tools; toda ferramenta que caia em WEB_TOOLS (WebFetch, WebSearch, roles.py:33) também é adicionada ao próprio allowed_tools do coordenador (roles.py:523-526).
Motivo: allowed_tools, assim como disallowed_tools, é de escopo de sessão. Esta é a evidência mais dura do texto inteiro sobre esse ponto — ela não afeta só a thread principal. Uma ferramenta que não esteja nessa lista de escopo de sessão também exige aprovação de permissão quando um subagent a chama; sem supervisão não há quem aprove, e o harness responde Claude requested permissions to use X, but you haven't granted it yet (toolDenialKind=user-rejected), enquanto o modelo repete a mesma chamada indefinidamente. Já tropeçamos nisso na prática: acrescentamos WebFetch/WebSearch ao worker, mas só escrevemos em AgentDefinition.tools; aquela run de novel teve mais de vinte user-rejected e não escreveu uma única palavra (roles.py:513-518).
Os dois campos têm a mesma natureza de escopo de sessão, mas sintomas diferentes: disallowed_tools dá erro na hora, allowed_tools fica repetindo em silêncio até morrer. O segundo é mais difícil de diagnosticar, porque nada na tela parece um erro.
Só as somente-leitura, sem efeitos colaterais, são mescladas. Write/Edit/Bash são deliberadamente deixadas de fora: se a thread principal ficar isenta de aprovação para elas, o muro do delegate_guard — "o coordenador não põe a mão na massa" — perde o sentido; e Bash/Write do subagent já funcionam de qualquer maneira (462 liberações medidas, roles.py:520-522).
O código-fonte diz explicitamente para não usar disallowed_tools para implementar "só coordena, não executa" — aquilo é de escopo de sessão, e desabilitaria junto o Bash/Write dos subagents; veja o aviso em AgentSpec. O jeito correto é o daqui: delegate_only=True + não conceder em allowed_tools, e deixar o delegate_guard interceptar apenas a thread principal por agent_id.
Fornecer channel traz as duas ferramentas juntas, não é opcional: montado o MCP server, as duas estão lá, e como allowed_tools não é exclusivo, ambas podem ser chamadas estejam listadas ou não. Sem supervisão, cada ask trava até estourar o timeout_s — nesses cenários, use HumanChannel(timeout_s=0).
worker()¶
def worker(
description: str,
prompt: str,
*,
tools: list[str] | None = None,
model: str = "inherit",
effort: str | int | None = None,
max_turns: int | None = None,
permission_mode: str | None = None,
skills: list[str] | None = None,
discipline: bool = True,
isolate: bool = False,
) -> AgentDefinition
Constrói a definição do subagent que de fato trabalha. Os dois primeiros parâmetros são posicionais. Retorna o AgentDefinition do SDK, para ser colocado diretamente em coordinator(workers={...}).
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
description | str | obrigatório | É por aqui que o coordenador escolhe quem chamar — deixe claro "que tipo de trabalho vai para ele" |
prompt | str | obrigatório | Seu system prompt. Com discipline=True, vira f"{prompt}\n\n{WORKER_RULES}" |
tools | list[str] \| None | None | None → Read Write Edit Bash Glob Grep WebFetch WebSearch |
model | str | "inherit" | O worker não deve ser rebaixado |
effort | str \| int \| None | None | Intensidade de raciocínio |
max_turns | int \| None | None | Vira maxTurns (camelCase) no SDK |
permission_mode | str \| None | None | Vira permissionMode (camelCase) no SDK |
skills | list[str] \| None | None | Quais skills ele pode usar |
discipline | bool | True | Concatenar ou não o trecho de disciplina de reporte WORKER_RULES |
isolate | bool | False | Marca de isolamento, passa por isolated(); não é um campo do AgentDefinition |
isolate=True exige que o workspace seja um repositório git; caso contrário, a ferramenta Agent reporta direto "not in a git repository", não há degradação silenciosa. E como a marca é um atributo Python, fazer dataclasses.replace() sobre o AgentDefinition a perde, e o isolamento deixa de valer silenciosamente.
clarify()¶
def clarify(
name: str,
channel: Any,
*,
instructions: str = "",
can_read: bool = True,
model: str | None = None,
effort: str | None = None,
max_turns: int | None = None,
max_budget_usd: float | None = None,
) -> AgentSpec
Constrói o clarificador: esclarece o requisito antes de agir, não faz nada além de perguntar, e no fim produz exatamente quatro seções.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | obrigatório | Nome do papel, posicional |
channel | HumanChannel | obrigatório | Canal de perguntas, posicional |
instructions | str | "" | Instruções complementares, concatenadas após CLARIFIER_RULES |
can_read | bool | True | Quando True, acrescenta Read Glob Grep WebFetch WebSearch |
model | str \| None | None | Modelo |
effort | str \| None | None | Intensidade de raciocínio |
max_turns | int \| None | None | Sem limite de turnos |
max_budget_usd | float \| None | None | Teto de orçamento |
O AgentSpec produzido: allowed_tools = [channel.tool_name] + (os cinco acima, quando pode ler), mcp_servers = channel.mcp_servers(), workbench=False (ele não tem ferramenta de escrita, o índice não lhe serve de nada), permission_mode herda o padrão "default" do AgentSpec. Sem Write / Edit / Bash / Agent, e sem inbox (diferente do coordenador).
Um max_turns pequeno transforma o "perguntar sem limite" em conversa fiada
Cada pergunta é um turno. max_turns=16 equivale a "no máximo uma dúzia de perguntas", e a frase do canal "não há limite de turnos" perde a validade na hora.
Para liberar as perguntas é preciso liberar os dois lugares: HumanChannel.max_asks (já é None = sem limite por padrão) e max_turns (já é None por padrão).
judge()¶
def judge(
name: str,
channel: Any,
*,
instructions: str = "",
can_run: bool = False,
model: str | None = None,
effort: str | None = None,
max_turns: int | None = None,
max_budget_usd: float | None = None,
) -> AgentSpec
Constrói o juiz: ou define o objetivo antes de começar, ou emite o veredito ao fim de cada rodada.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | obrigatório | Nome do papel, posicional |
channel | HumanChannel | obrigatório | Canal de perguntas, posicional |
instructions | str | "" | Instruções complementares, concatenadas após JUDGE_RULES |
can_run | bool | False | Quando True, acrescenta Bash à whitelist; o whitelist_guard passa a liberar Bash e continua barrando Write/Edit |
model | str \| None | None | Modelo |
effort | str \| None | None | Intensidade de raciocínio |
max_turns | int \| None | None | Limite de turnos |
max_budget_usd | float \| None | None | Teto de orçamento |
O AgentSpec produzido: allowed_tools = [channel.tool_name, "Read", "Glob", "Grep"] + (quando can_run) ["Bash"], workbench=False, o resto igual a clarify(). Sem Write / Edit / Agent, e sem inbox.
Trade-off: can_run=True dá um veredito mais duro (consegue de fato rodar os comandos de aceitação), ao custo de o juiz passar a poder alterar o workspace — Bash por si só já escreve arquivos. Se você quer um veredito absolutamente neutro, não ligue.
oracle()¶
def oracle(
name: str = "旁路问答",
*,
instructions: str = "",
model: str | None = None,
effort: str | None = None,
max_turns: int | None = 12,
max_budget_usd: float | None = 0.5,
) -> AgentSpec
Constrói o oracle: com a run ainda em andamento, você pergunta "onde estamos agora" e ele dá uma olhada nos eventos recentes e na bancada antes de responder. O que ele diz não entra no contexto daquela run.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | "旁路问答" | Nome do papel, posicional |
instructions | str | "" | Instruções complementares, concatenadas após ORACLE_RULES |
model | str \| None | None | Modelo |
effort | str \| None | None | Intensidade de raciocínio |
max_turns | int \| None | 12 | Vem com freio por padrão |
max_budget_usd | float \| None | 0.5 | Vem com freio por padrão. Isto é "uma perguntinha de passagem", não deve sair do controle |
O AgentSpec produzido: allowed_tools = ["Read", "Glob", "Grep"] (sem channel — ele não pergunta, só responde), workbench=True (o único dos cinco papéis que não é coordenador e ainda assim abre a bancada — é exatamente lá que ele vai ler os artefatos e as notas).
Os cinco textos de regras¶
As cinco constantes estão em __all__; dá para import direto e ler, concatenar, alterar.
| Constante | Injetada em quem | Forma de injeção | Pontos-chave |
|---|---|---|---|
COORDINATOR_RULES | coordinator() | f"{RULES}\n{instructions}".strip() | Você é "uma pessoa que sabe usar o Claude Code", não um worker; não pode escrever arquivos/alterar código/rodar testes; Bash só dá para "dar uma olhada" e o resultado envelhece; o briefing de tarefa só escreve o que é exclusivo desta tarefa; a única regra que ainda precisa ser dita é "onde fica a bancada + artefatos longos vão para artifacts/ + a resposta traz só o caminho"; consultar o inbox a cada ação concluída; ask bloqueia, use só em bifurcações de verdade |
WORKER_RULES | worker() | Concatenado depois do prompt do subagent | Formato de resposta conclusão / evidências / artefatos / não verificado, no máximo 30 linhas; proibido colar conteúdo de arquivo, saída de comando, log ou diff bruto; proibido narrar a sequência de tentativa e erro; antes de agir, olhar .flower/scripts/. Deliberadamente não menciona "artefatos longos vão para artifacts/" — o caminho real é gerado pelo Workbench, cravá-lo daria errado |
CLARIFIER_RULES | clarify() | f"{RULES}\n{instructions}".strip() | Não faz nada, só esclarece o requisito; sem limite de perguntas, pergunte até ficar claro; a pessoa pode não estar; ao dar timeout, decida sozinho e registre em «Incógnitas e premissas»; a saída tem exatamente quatro seções; não escreva código nem cole conteúdo de arquivo |
JUDGE_RULES | judge() | f"{RULES}\n{instructions}".strip() | Duas tarefas, escolha uma. Definir o objetivo: cada item da lista tem de ser verificável na hora, o tamanho da lista é determinado pelo número de modos de falha, fronteiras não são itens de veredito, e itens não verificáveis terminam com [此环境无法验证:原因]. Julgar a rodada: saída com exatamente três seções, julga-se o artefato, não o código-fonte, por padrão não se acredita em "está pronto"; "não conseguiu" e "aqui não dá para verificar" são conclusões diferentes, e a segunda jamais pode ser julgada como aprovada |
ORACLE_RULES | oracle() | f"{RULES}\n{instructions}".strip() | Você é uma via lateral; a run continua rodando, você não interrompe nem participa; somente leitura; responde e é descartado, o que você diz não entra no contexto daquela run; você só tem "a janela de eventos recentes" e "a bancada"; olhe antes de responder, se não der para responder diga que não dá, e seja breve |
Definição de agent¶
Código-fonte: flower/core/agent.py
AgentSpec é a declaração completa de um agent especializado, e build_options a compila no ClaudeAgentOptions do SDK. O que a fábrica de papéis produz é justamente um AgentSpec — quando você precisa de uma combinação fora da fábrica, construa-o diretamente.
AgentSpec¶
@dataclass
class AgentSpec:
name: str
instructions: str
allowed_tools: list[str] = field(default_factory=lambda: ["Read", "Glob", "Grep"])
disallowed_tools: list[str] = field(default_factory=list)
model: str | None = None
effort: str | None = None
max_turns: int | None = None
max_budget_usd: float | None = None
permission_mode: str = "default"
agents: dict[str, Any] | None = None
mcp_servers: dict[str, Any] = field(default_factory=dict)
hooks: dict[str, Any] | None = None
compact: CompactPolicy | None = None
env: dict[str, str] = field(default_factory=dict)
glance: bool = False
workbench: bool = True
delegate_only: bool = False
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
name | str | obrigatório | Nome do papel. Também é o step_name padrão de Runtime.run, e como o whitelist_guard se refere a si mesmo no texto de recusa |
instructions | str | obrigatório | Instruções de domínio. Anexadas depois do system prompt nativo do Claude Code, não substituem |
allowed_tools | list[str] | ["Read", "Glob", "Grep"] | Lista de isenção de aprovação, não é uma whitelist exclusiva — o modelo continua conseguindo chamar ferramentas fora dela. A exclusividade vem do whitelist_guard |
disallowed_tools | list[str] | [] | Escopo de sessão. Veja o aviso abaixo |
model | str \| None | None | Modelo |
effort | str \| None | None | Intensidade de raciocínio |
max_turns | int \| None | None | Limite de turnos |
max_budget_usd | float \| None | None | Teto de orçamento |
permission_mode | str | "default" | Modo de permissão |
agents | dict[str, Any] \| None | None | Tabela de definições de subagents; os valores são AgentDefinition |
mcp_servers | dict[str, Any] | {} | Tabela de MCP servers. HumanChannel.mcp_servers() preenche direto aqui |
hooks | dict[str, Any] \| None | None | Hooks extras; o Runtime os mescla com os seus via merge_hooks |
compact | CompactPolicy \| None | None | Se fornecido, não será forçado pelo Runtime para no_summary |
env | dict[str, str] | {} | Variáveis de ambiente injetadas no subprocesso. compact.env() faz update por cima |
glance | bool | False | Permite ao coordenador rodar por conta própria um Bash de "só dar uma olhada". O que é liberado é decidido por is_ephemeral, e o resultado é marcado como expirado pelo EphemeralPolicy |
workbench | bool | True | Se injeta ou não o índice da bancada no system prompt deste agent. Papéis sem ferramenta de escrita devem desligar (clarify() / judge() já vêm com False) |
delegate_only | bool | False | Só coordena, não executa. Quando True, o Runtime instala delegate_guard e não instala whitelist_guard |
disallowed_tools é de escopo de sessão e desabilita junto os subagents
Erro medido, texto original: "Bash is disabled for this session, in subagents as well as here". Ou seja: se você usar disallowed_tools=["Bash"] para impedir o coordenador de agir, os workers despachados também não conseguem rodar comandos — a run inteira vai por água abaixo.
Para "só coordena, não executa", use delegate_only=True + não conceder em allowed_tools, deixando o delegate_guard interceptar apenas a thread principal por agent_id.
build_options()¶
def build_options(
spec: AgentSpec,
*,
cwd: str | Path | None = None,
session_store: SessionStore | None = None,
resume: str | None = None,
fork: bool = False,
resume_at: str | None = None,
use_plugin: bool = True,
portable: bool = True,
add_dirs: list[str] | None = None,
flush: str = "eager",
prelude: str = "",
) -> ClaudeAgentOptions
Compila o AgentSpec no ClaudeAgentOptions do SDK. É o que Runtime._attempt chama internamente; quando você mesmo dirige o SDK (sem Runtime), a entrada também é por aqui.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
spec | AgentSpec | obrigatório, posicional | A declaração a compilar |
cwd | str \| Path \| None | None | Só escreve cwd se não for None |
session_store | SessionStore \| None | None | Só escreve session_store e session_store_flush se não for None |
resume | str \| None | None | Qual sessão retomar |
fork | bool | False | Vira fork_session. Está aninhado dentro de if resume: |
resume_at | str \| None | None | Vira resume_session_at. Também aninhado dentro de if resume: |
use_plugin | bool | True | True e PLUGIN_DIR existindo → plugins=[{"type": "local", "path": ...}] |
portable | bool | True | True → setting_sources=[]; False → ["project"] |
add_dirs | list[str] \| None | None | Diretórios autorizados adicionais. Obrigatório quando a bancada fica fora do workspace |
flush | str | "eager" | Vira session_store_flush |
prelude | str | "" | Trecho anexado depois de instructions (o índice da bancada passa por aqui) |
Mapeamento:
| Chave de option produzida | Valor |
|---|---|
system_prompt | {"type": "preset", "preset": "claude_code", "append": spec.instructions [+ "\n\n" + prelude]} |
allowed_tools / disallowed_tools / permission_mode | Vêm diretamente do spec |
setting_sources | [] (portável) ou ["project"] |
plugins | Só existe se o diretório plugin/ na raiz do repositório existir |
cwd / add_dirs | Só escritos se não vazios |
session_store / session_store_flush | Só escritos se session_store não for None |
model effort max_turns max_budget_usd agents mcp_servers hooks | Cada um só é escrito se não for vazio |
env | dict(spec.env) e depois update(spec.compact.env()) |
resume / fork_session / resume_session_at | Só têm efeito quando resume é verdadeiro |
PLUGIN_DIR é o plugin/ na raiz do repositório (três níveis acima de flower/core/agent.py). Após instalar por pip esse diretório pode não existir; o código verifica com is_dir().
fork=True sem resume não faz nada, em silêncio
fork_session e resume_session_at estão ambos aninhados dentro de if resume: — sem resume eles simplesmente não têm efeito, e nenhum erro é levantado. Do mesmo modo, Runtime.run(resume_at=...) só funciona quando resume é dado, e o Workflow nunca passa resume_at: para voltar até uma mensagem específica, só chamando Runtime.run diretamente.
CompactPolicy¶
@dataclass
class CompactPolicy:
mode: str = "auto"
window: int | None = None
def env(self) -> dict[str, str]: ...
O painel de chaves do auto-compact; o produto é um conjunto de variáveis de ambiente a injetar no subprocesso. O algoritmo de compact em si está no binário do harness e não pode ser mudado; o que dá para mudar é apenas "dispara ou não".
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
mode | str | "auto" | "auto" = não define nada, limiar = janela − 33k; "no_summary" → DISABLE_AUTO_COMPACT=1; "off" → DISABLE_COMPACT=1 (desliga junto o /compact). Outros valores levantam ValueError, não são ignorados em silêncio |
window | int \| None | None | Se não for None → CLAUDE_CODE_AUTO_COMPACT_WINDOW=<str(window)>. O lado do CLI limita a 100k–1M; valores abaixo de 100k são elevados para 100k |
| Método | Assinatura | Descrição |
|---|---|---|
env | () -> dict[str, str] | Produz as variáveis de ambiente. É aqui que um mode inválido levanta ValueError, não na construção — como é chamado por build_options, o erro aparece dentro de Runtime.run |
HandoffPolicy¶
@dataclass
class HandoffPolicy:
enabled: bool = True
window: int = field(default_factory=default_window)
headroom: int = 50_000
max_generations: int = 8
@property
def at(self) -> int: ... # max(10_000, window - headroom)
@property
def warn_at(self) -> int: ... # max(1_000, at - 20_000)
O objeto de política que, quando o contexto está quase cheio, "escreve o documento de handoff e troca de sessão" em vez de fazer compact.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
enabled | bool | True | Desligar faz cair de volta no auto-compact |
window | int | default_window() | Qual o tamanho assumido da janela de contexto do modelo |
headroom | int | 50_000 | Quanta folga deixar. Motivo: o auto-compact dispara em janela −33k, e o handoff precisa acontecer antes dele — e "escrever o handoff" ainda consome um turno |
max_generations | int | 8 | Quantas gerações no máximo em um step. Isto é um freio contra descontrole, não planejamento de capacidade |
| Propriedade | Tipo | Descrição |
|---|---|---|
at | @property -> int | Limiar de handoff max(10_000, window - headroom). Tem piso de 10k — abaixo disso nem o handoff sai |
warn_at | @property -> int | Posição do aviso de aproximação max(1_000, at - 20_000), emitido uma vez por geração |
Um window pequeno demais gera handoffs infinitos queimando dinheiro
Se at ficar abaixo do piso de partida daquele papel (medido em cerca de 34k para o coordenador), toda nova sessão cruza a linha na primeira fala; e como handoff não consome cota de retentativa (attempt -= 1), o sistema gira em falso indefinidamente. O único freio é max_generations=8; ao bater nele, o error é substituído por um diagnóstico sugerindo aumentar window ou desligar o handoff.
default_window()¶
Adivinha a janela de contexto a partir da string do nome do modelo nas variáveis de ambiente ANTHROPIC_MODEL ou ANTHROPIC_DEFAULT_OPUS_MODEL:
| Condição | Retorno |
|---|---|
O nome contém a palavra isolada 1m (regex (?:^\|[^a-z0-9])1m(?:[^a-z0-9]\|$)) | 1_000_000 |
O nome contém haiku | 200_000 |
| Demais casos (inclusive nenhuma das duas variáveis definida) | 1_000_000 |
O padrão é o valor agressivo. Estimar alto não é um erro fatal: a API rejeita com prompt is too long, o Runtime reconhece esse sinal (o is_overflow interno) e faz handoff na hora — mas o handoff dessa geração sai em versão degradada.
Documentos¶
Código-fonte: brief.py · handoff.py · goal.py
Quatro dataclasses, todas "parseiam uma resposta do modelo em um número fixo de seções e gravam em disco". Formato comum: parse() parseia, missing() / complete() verificam completude, to_markdown() é para humanos, prompt_block() é para os modelos a jusante, write() / load() gravam e leem de volta.
Brief¶
@dataclass
class Brief:
goal: str = ""
accept: str = ""
bounds: str = ""
unknowns: str = ""
path: Path | None = field(default=None, compare=False)
O brief, exatamente quatro seções, na ordem fixa goal → accept → bounds → unknowns; os nomes em chinês são «目标», «验收标准», «边界», «未知与假设».
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
goal | str | "" | Objetivo |
accept | str | "" | Critérios de aceitação |
bounds | str | "" | Fronteiras |
unknowns | str | "" | Incógnitas e premissas |
path | Path \| None | None | Local de gravação. compare=False, não participa da comparação de igualdade |
| Método | Assinatura | Descrição |
|---|---|---|
missing | () -> list[str] | Nomes em chinês das seções faltantes, exibíveis diretamente |
complete | () -> bool | not missing() |
parse | @classmethod (text: str) -> Brief | Parseia as quatro seções da resposta do modelo. Primeiro remove os blocos de código cercados; o que não for parseado fica vazio |
to_markdown | () -> str | Documento completo com cabeçalho de metadados; seções vazias viram "(未填)" |
prompt_block | () -> str | Versão compacta para alimentar os agentes a jusante, só com as seções não vazias, sem metadados |
write | (path: str \| Path) -> Path | Cria o diretório pai, grava, define self.path como o caminho resolvido e o retorna |
load | @classmethod (path: str \| Path) -> Brief \| None | Retorna None se o arquivo não existir ou em OSError. Converte o placeholder "(未填)" de volta em string vazia |
Regras de parsing (onde os erros se concentram):
- Ao remover as cercas, **um
`` ou~não fechado faz descartar tudo a partir dele** — na prática o clarificador cola o código inteiro na resposta. Quando a saída do modelo é truncada, todas as seções seguintes deixam de ser parseadas,complete()ficaFalse` e o gate manda refazer. - A regex de título tolera
## 目标/**目标**/目标:/3. 边界, e também tolera texto logo após o título. - A tabela de aliases é compilada em ordem decrescente de comprimento; senão "未知" engoliria "未知与假设" primeiro.
- Quando a mesma seção aparece repetida, prevalece a primeira com conteúdo.
- Se, ao editar o brief à mão, você copiar o placeholder
"(未填)"deto_markdown(), aquela seção continua contando como faltante.
Handoff¶
@dataclass
class Handoff:
doing: str = ""
decided: str = ""
deadends: str = ""
next: str = ""
scene: str = ""
step: str = ""
path: Path | None = field(default=None, compare=False)
O documento de handoff escrito na hora do handoff, cinco seções.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
doing | str | "" | O que está sendo feito. Obrigatório |
decided | str | "" | O que já foi decidido |
deadends | str | "" | Caminhos que não deram certo |
next | str | "" | Próximo passo. Obrigatório |
scene | str | "" | Situação atual |
step | str | "" | Usado apenas no cabeçalho do documento, não participa do parsing |
path | Path \| None | None | Local de gravação |
Obrigatórias são só doing e next — exigir rigidamente que "caminhos que não deram certo" seja não vazio forçaria o modelo a inventar.
| Membro | Assinatura | Descrição |
|---|---|---|
missing | () -> list[str] | Verifica apenas as duas seções obrigatórias |
complete | () -> bool | not missing() |
degraded | @property -> bool | Se o corpo carrega a marca de degradação [降级:交接没写成] |
parse | @classmethod (text: str, *, step: str = "") -> Handoff | Reaproveita o divisor de seções do Brief |
to_markdown | () -> str | Seções vazias viram "(空)" |
prompt_block | () -> str | O cabeçalho diz explicitamente a quem assume que "você está assumindo", para evitar que ele volte a pedir contexto a alguém |
write | (path) -> Path | Igual a Brief.write |
load | @classmethod (path) -> Handoff \| None | Igual a Brief.load |
Três membros do mesmo módulo não exportados, mas semanticamente cruciais: is_overflow(*texts) casa com prompt is too long, context length exceeded, maximum context length, too many total text bytes, input length and max_tokens exceed etc., convertendo um "erro fatal" em "handoff imediato"; HANDOFF_PROMPT é o prompt que faz a própria sessão atual escrever o handoff (contém os dois placeholders {used} e {window}; não é um novo papel — só ela tem aquele contexto); degraded(step, prompt, *, why="") monta mecanicamente um handoff quando ele não pôde ser escrito, enfiando em scene os primeiros 1200 caracteres da tarefa original.
Goal¶
@dataclass
class Goal:
statement: str = ""
checks: list[str] = field(default_factory=list)
path: Path | None = None
O objetivo + a lista de verificação usados pelo goal guard.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
statement | str | "" | Enunciado do objetivo |
checks | list[str] | [] | Lista de verificação, um item por linha |
path | Path \| None | None | Local de gravação |
| Membro | Assinatura | Descrição |
|---|---|---|
unverifiable | @property -> list[str] | Itens de checks marcados com [此环境无法验证:…]. Já nascem condenados a nunca passar, desde o momento em que o objetivo foi definido |
missing | () -> list[str] | Exige statement não vazio e checks não vazio |
complete | () -> bool | not missing() |
parse | @classmethod (text: str) -> Goal | checks um por linha, removendo automaticamente os marcadores - / * / 1. |
to_markdown | () -> str | Quando a lista está vazia, escreve "(空)" |
prompt_block | () -> str | Versão compacta para alimentar os agentes a jusante |
write / load | Igual a Brief | Gravação e leitura de volta |
amend | (extra: str) -> Goal | Anexa, não sobrescreve: concatena "\n\n(已修改)" + extra após statement e retorna self |
Verdict¶
@dataclass
class Verdict:
state: str = ""
reason: str = ""
failed: list[str] = field(default_factory=list)
O resultado de uma rodada de julgamento do juiz, exatamente três seções: conclusão / motivo / não aprovados.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
state | str | "" | "achieved" / "not_yet" / "unreachable"; "" quando não se consegue parsear |
reason | str | "" | Motivo |
failed | list[str] | [] | Itens da lista que não passaram |
| Membro | Assinatura | Descrição |
|---|---|---|
achieved | @property -> bool | state == "achieved" |
unreachable | @property -> bool | state == "unreachable" |
ok | @property -> bool | Se a conclusão foi parseada ou não. ok=False tem obrigatoriamente de ser tratado como "não atingido", nunca como atingido |
parse | @classmethod (text) -> Verdict | Veja abaixo |
feedback | () -> str | O que é devolvido ao worker: só "o que está faltando", nunca a solução |
Ordem de reconhecimento do parse:
- Primeiro busca as seções tituladas «结论» / «判定».
- Sem seções tituladas, faz strip do texto inteiro e
fullmatch(r"1|true")→ atingido;fullmatch(r"0|false")→ ainda não. - Caso contrário, procura no texto da conclusão a primeira ocorrência de uma palavra da tabela de estados (palavras longas primeiro). «无法验证 / 没法验证 / 验证不了 / 无法判定 / unverifiable» são todas mapeadas para
unreachable— já tropeçamos nisso: plataforma-alvo macOS, execução em container Linux, e o juiz olhou o branch no código-fonte e deu aprovado. - Ainda nada → procura um
\b1\bisolado → atingido,\b0\b→ ainda não. - Nenhum caso →
state="",ok=False.
unreachable e not_yet são conclusões diferentes: a primeira leva ao caminho de "parar e perguntar a alguém", não ao de "mais uma rodada".
Camada de hooks¶
Código-fonte: flower/core/guard.py
Esta camada é a fronteira de execução do flower: quais ferramentas a thread principal não pode tocar, como recortar resultados gigantes, qual papel vai para um worktree independente — tudo é imposto por hooks do SDK, não por prompt. O motivo é direto: prompt é sugestão, o modelo pode ignorar. Já foi medido na prática que, mesmo com o system prompt dizendo explicitamente "não use worktree", a injeção do isolate_guard funcionou do mesmo jeito (o modelo passou None, o que chegou no disco foi 'worktree').
Nove exports: cinco fábricas de guard que retornam HookMatcher (whitelist_guard pode retornar None), um montador, um combinador, duas funções de marcação de isolamento. Não é preciso instalá-los à mão — o Runtime monta tudo automaticamente a partir do AgentSpec. Instalação manual só é necessária quando você mesmo dirige o SDK (sem passar pelo Runtime).
A detecção da thread principal passa por uma única função: _is_main_thread(data) = not data.get("agent_id") — os dados de tool-lifecycle hook de um subagent trazem agent_id, os da thread principal não. Todos os guards que "só barram a thread principal" se apoiam nessa única regra.
Constantes de grupos de ferramentas (nível de módulo, não exportadas, mas definem os matchers padrão):
HANDS_ON = "Bash|Write|Edit|NotebookEdit"
WRITE_ONLY = "Write|Edit|NotebookEdit"
BULKY = "Bash|Read|Grep|Glob|WebFetch|WebSearch"
Tabela de referência: qual guard entra em qual evento do SDK¶
| Função | Evento de hook do SDK | matcher | O que intercepta | O que retorna | Quem instala |
|---|---|---|---|---|---|
whitelist_guard | PreToolUse | as ferramentas de Bash\|Write\|Edit\|NotebookEdit que não estão em allowed_tools | apenas a thread principal chamando ferramenta proibida | permissionDecision: "deny" + motivo | Runtime._attempt, apenas quando spec.delegate_only is False |
delegate_guard | PreToolUse | Bash\|Write\|Edit\|NotebookEdit (alterável por tools=) | apenas a thread principal pondo a mão; com allow_glance=True, um Bash que passa em is_ephemeral() é liberado | deny + "vá delegar a um subagent" | workbench_hooks(delegate_only=True), apenas quando o Runtime tem workbench |
isolate_guard | PreToolUse | Agent | tool_input sem cwd e sem isolation, e subagent_type marcado por isolated() | permissionDecision: "allow" + updatedInput (injeta isolation="worktree") | workbench_hooks, apenas quando há algum papel marcado em agents |
index_guard | PostToolUse | Write\|Edit | tool_input.file_path cai dentro de workbench.root | {} (o efeito colateral é workbench.refresh()) | workbench_hooks, sempre instalado |
spill_guard | PostToolUse | Bash\|Read\|Grep\|Glob\|WebFetch\|WebSearch | campos string com ≥ threshold caracteres em tool_response; ler o próprio diretório de spill é liberado | updatedToolOutput (spill + uma linha de ponteiro + os 400 primeiros caracteres) | workbench_hooks, apenas quando spill_threshold for verdadeiro |
A conclusão importante que se lê nessa tabela: com Runtime(workbench=False), os workbench_hooks inteiros não são instalados; e para um coordenador com delegate_only=True, o whitelist_guard também é pulado — a thread principal fica sem nenhuma barreira. Veja o aviso em Runtime.
whitelist_guard()¶
Faz allowed_tools ser de fato exclusivo para as quatro ferramentas de ação.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
allowed | list[str] \| None | obrigatório, posicional | normalmente se passa spec.allowed_tools direto |
role | str | "这个角色" | como o papel se refere a si mesmo no texto de recusa. O Runtime passa spec.name |
- Entra em
PreToolUse, com matcher"|".join(banned), ondebanned= as ferramentas entreBashWriteEditNotebookEditque não estão emallowed. - Ao acertar,
permissionDecision: "deny", com texto no espírito de: "XX não tem YY. Isso é intencional, não é configuração faltando. Escreva a conclusão no corpo da sua resposta, o framework a lê de lá — não tente outra formulação para contornar." - Só barra a thread principal desta sessão, subagents passam — as ferramentas de um subagent são decididas por
AgentDefinition.tools. - Quando não há nada a barrar, retorna
None(por exemplo, um papel com o kit completo comoworker()); quem chama decide se instala ou não.
Por que ele precisa existir: allowed_tools é uma lista de dispensa de aprovação, não uma whitelist exclusiva. Duas evidências medidas na prática: um judge com meta definida rodou Bash 11 vezes; na sonda de $0.1, um agent com allowed_tools=["Read"] chamou Write/Bash sem problema. Ou seja, o "não tem ferramenta de escrita" de clarify() / judge() vem deste hook, não da whitelist em si.
A vantagem é que ele deriva de allowed_tools, então judge(can_run=True) mantém Bash automaticamente e continua barrando Write/Edit — sem precisar de flag extra.
delegate_guard()¶
Thread principal pondo a mão → recusa, com indicação do caminho.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
tools | str | "Bash\|Write\|Edit\|NotebookEdit" | matcher. É uma string de regex, não uma lista |
allow_glance | bool | False | com True, libera quando tool_name == "Bash" e is_ephemeral(command) é verdadeiro |
- Entra em
PreToolUse, matcher é o própriotools. - Thread principal chamando uma dessas quatro ferramentas → deny, e o motivo diz qual é o próximo passo: usar a ferramenta
Agentpara despachar um subagent, escrever no encargo o objetivo e os critérios de aceitação, e exigir que ele escreva produções longas em.flower/artifacts/e devolva só caminho e conclusão. - Subagents sempre passam.
A diferença para o whitelist_guard é a formulação: ambos barram o mesmo conjunto de ferramentas, mas este diz "vá delegar", que é o mais adequado ali. Por isso um papel com delegate_only=True instala só este; instalar os dois faria o modelo receber duas orientações contraditórias.
O critério de liberação de allow_glance=True e o critério de "o resultado vai ser recortado" são a mesma função (is_ephemeral) — o conjunto liberado tem que ser igual ao conjunto que expira; mexeu em um lado, tem que mexer no outro.
spill_guard()¶
def spill_guard(
workbench: Workbench,
*,
threshold: int = 4000,
tools: str = BULKY,
main_only: bool = False,
) -> HookMatcher
Resultados de ferramenta acima do limiar vão para o disco na hora (spill), deixando no contexto só uma linha de ponteiro — não é esperar o contexto encher para depois compactar.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
workbench | Workbench | obrigatório, posicional | o diretório de spill é <workbench.root>/spill/ |
threshold | int | 4000 | a partir de quantos caracteres vai para o disco |
tools | str | "Bash\|Read\|Grep\|Glob\|WebFetch\|WebSearch" | matcher |
main_only | bool | False | False (padrão) = resultados de subagent também sofrem spill |
- Entra em
PostToolUse, retorna{"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": <recortado>}}. - O nome do arquivo é os 16 primeiros caracteres do
sha256do conteúdo +.txt; no contexto entra uma linha de ponteiro + os 400 primeiros caracteres. - O
updatedToolOutputprecisa preservar a estrutura de saída da ferramenta original, então só os campos string longos demais do dict são substituídos; listas nunca são tocadas (podem conter blocos de imagem). Estrutura errada é rejeitada (fica o original, sem erro). - Ler o próprio arquivo de spill tem que ser liberado — senão "use
Readpara lê-lo" é conversa fiada: o texto lido de volta sofre spill de novo, em loop infinito. Já aconteceu na prática; o modelo tentou cinco formulações seguidas para contornar.
index_guard()¶
Escreveu algo no workbench, o INDEX.md é atualizado, e o próximo agent já começa sabendo que aquilo existe.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
workbench | Workbench | obrigatório, posicional | escopo da checagem e alvo do refresh |
Entra em PostToolUse, matcher "Write|Edit". Se tool_input["file_path"], depois de resolvido, cai dentro de workbench.root, chama workbench.refresh(). Sempre retorna {} — não altera nada, só tem efeito colateral.
isolate_guard()¶
Dá a cada subagent, conforme o papel, um git worktree independente — é assim que o isolamento acontece.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
agents | dict[str, AgentDefinition] | obrigatório, posicional | tabela de papéis, usada para ver se o subagent_type está marcado |
on_inject | Any | None | callback opcional, chamado como on_inject(subagent_type, description) |
Entra em PreToolUse, matcher "Agent". A injeção só ocorre com as três condições simultâneas: tool_name == "Agent", tool_input sem cwd e sem isolation, e o papel correspondente ao subagent_type marcado por isolated(). Nesse caso retorna permissionDecision: "allow" + updatedInput (define isolation como "worktree").
isolation e cwd são mutuamente exclusivos na ferramenta Agent — se o modelo especificou um cwd, isso é respeitado. "Isolar ou não" é uma propriedade do papel, não um botão global nem uma decisão tomada a cada despacho; papéis que não precisam de isolamento não ganham um byte a mais.
Ligar isolamento exige tirar o workbench de dentro do repositório. Um agent isolado não consegue escrever no checkout compartilhado, então o workbench precisa apontar para fora do repositório via home=. starter_flow(isolate=True) usa <ws>.parent/.flower-<ws.name>, Runtime(workbench=True) usa <run_dir>/workbench — os dois ficam fora do repositório, mas não são o mesmo diretório, não misture.
isolated() / wants_isolation()¶
def isolated(agent: AgentDefinition, flag: bool = True) -> AgentDefinition
def wants_isolation(agent: AgentDefinition | None) -> bool
Marca uma definição de subagent como "precisa de workspace próprio", e lê essa marca de volta.
| Função | Parâmetro | Padrão | Descrição |
|---|---|---|---|
isolated | agent: AgentDefinition | obrigatório | a definição a marcar. Retorna o mesmo objeto |
flag: bool | True | posicional. False = remove a marca | |
wants_isolation | agent: AgentDefinition \| None | obrigatório | aceita None também, retornando False |
A marca é um atributo _flower_isolate do lado Python, gravado com object.__setattr__, não é um campo de dataclass — o SDK serializa com asdict() e só reconhece campos declarados, então essa marca não vaza para o lado da CLI (medido na prática).
Custo: fazer dataclasses.replace() sobre um AgentDefinition perde essa marca, e o isolamento falha silenciosamente.
Por dentro, worker(isolate=True) passa por isolated().
workbench_hooks()¶
def workbench_hooks(
workbench: Workbench,
*,
delegate_only: bool = True,
spill_threshold: int | None = 4000,
agents: dict[str, AgentDefinition] | None = None,
allow_glance: bool = False,
) -> dict[str, list[HookMatcher]]
Instala de uma vez os hooks de que o workbench precisa. É o que Runtime._attempt chama.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
workbench | Workbench | obrigatório, posicional | repassado a index_guard e spill_guard |
delegate_only | bool | True | só instala delegate_guard quando True |
spill_threshold | int \| None | 4000 | só instala spill_guard quando verdadeiro |
agents | dict[str, AgentDefinition] \| None | None | se qualquer um deles estiver marcado com isolated(), acrescenta isolate_guard |
allow_glance | bool | False | repassado a delegate_guard(allow_glance=) |
Saída:
PreToolUse:delegate_only=True→[delegate_guard(allow_glance=allow_glance)]; havendo papel marcado → acrescentaisolate_guard(agents).PostToolUse: sempre[index_guard(workbench)];spill_thresholdverdadeiro → acrescentaspill_guard(workbench, threshold=spill_threshold).- Chaves de evento com lista vazia são removidas, não se retorna lista vazia.
merge_hooks()¶
Concatena vários grupos de configuração de hook por nome de evento.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
*groups | dict[str, list[Any]] \| None | variádico | qualquer quantidade de grupos. Grupos None são pulados |
Usa extend, sem deduplicação — passar o mesmo guard duas vezes o instala duas vezes. O Runtime o usa para juntar spec.hooks, workbench_hooks(...) e whitelist_guard.
Workbench¶
Código-fonte: flower/core/workbench.py
Workbench¶
@dataclass
class Workbench:
workspace: Path
dirname: str = ".flower"
max_index_entries: int = 40
home: Path | None = None
O diretório de trabalho em disco: três subdiretórios + um índice. O índice é injetado no system prompt, então o agent sabe a cada turno o que tem em mãos.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
workspace | Path | obrigatório, posicional | o workspace. __post_init__ faz resolve |
dirname | str | ".flower" | nome do diretório do workbench, relativo a workspace |
max_index_entries | int | 40 | só afeta prompt_block(): quantas entradas por categoria no trecho injetado no system prompt; o excedente vira uma linha "…mais N". O próprio INDEX.md não tem limite, lista tudo |
home | Path \| None | None | se dado, é usado como root e dirname é ignorado. Quando não for None, também sofre resolve |
| Membro | Assinatura | Descrição |
|---|---|---|
root | @property -> Path | usa home se dado, senão workspace / dirname |
external | @property -> bool | se root está fora de workspace. Em modo isolado deve ser True |
scripts | @property -> Path | root / "scripts", scripts que serão rodados uma segunda vez |
artifacts | @property -> Path | root / "artifacts", produções longas acima de 2000 caracteres |
notes | @property -> Path | root / "notes", decisões-chave, um arquivo por decisão |
index_path | @property -> Path | root / "INDEX.md" |
show | (p: Path) -> str | o caminho mostrado ao modelo: relativo dentro do workspace, absoluto fora dele |
ensure | () -> Workbench | faz mkdir dos três diretórios e retorna self (encadeável: Workbench(ws).ensure()) |
scan | (d: Path) -> list[tuple[str, str, int]] | (caminho exibido, descrição, bytes). rglob("*") recursivo, pulando arquivos que começam com . |
refresh | () -> str | reescreve o INDEX.md e devolve o conteúdo |
prompt_block | () -> str | o trecho injetado no system prompt. Curto de propósito — ele está lá em todos os turnos |
Formato de autodescrição de script: # desc: uma frase dentro das 8 primeiras linhas (também reconhece os comentadores // e --), com fallback para o primeiro comentário não vazio ou a primeira linha da docstring (cortada em 100 caracteres).
As três regras que prompt_block() injeta:
- Scripts que serão rodados uma segunda vez vão em
scripts/, com# desc:na primeira linha. - Produções acima de 2000 caracteres vão em
artifacts/; na conversa, só caminho e conclusão. - Decisões-chave vão em
notes/, um arquivo por decisão.
Quando external=True, prompt_block() acrescenta uma frase dizendo "acesse por caminho absoluto".
O índice não é herdado pelos subagents. Ele vai pelo system_prompt.append no nível da sessão, e subagents têm system prompt próprio (medido: $0.2461). Por isso as duas informações — "produção longa vai em artifacts/" e "onde fica o workbench" — precisam ser repassadas pelo coordenador dentro do task brief: esse é o único canal, não é redundância. Em WORKER_RULES isso foi deliberadamente omitido: o caminho real é gerado pelo Workbench, escrevê-lo fixo daria errado.
Session store¶
Código-fonte: sqlite.py · trim.py · prune.py
Três níveis de herança: SqliteSessionStore ← TrimmingSessionStore ← PruningSessionStore. O Runtime usa sempre o mais externo; as políticas dos três níveis são controladas por parâmetros de construção.
Cada nível cuida de uma coisa: persistir, fazer trim por volume e valor, e fazer prune pelo critério "isso é um erro?". Tanto o trim quanto o prune acontecem no momento do load() (ou seja, quando o resume devolve o histórico ao modelo); os registros originais no SQLite não mudam um byte.
SqliteSessionStore¶
Implementa o protocolo SessionStore do SDK, com três tabelas: entries / meta / summaries. A chave do store é project_key/session_id[/subpath] — o transcript de um subagent se distingue pelo subpath.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
path | str \| Path | obrigatório, posicional | arquivo do banco. A conexão usa check_same_thread=False |
| Método | Assinatura | Descrição |
|---|---|---|
append | async (key, entries) -> None | deduplicação idempotente por uuid (primeiro remove os já gravados, depois os repetidos dentro do lote). Em replay de um lote inteiro, não avança o mtime nem refaz o fold do summary; só o transcript principal (subpath is None) participa do summary |
projects | () -> list[str] | os project_key que existem de fato no banco. O SDK o deriva do cwd; confirme com isto antes de consultar, não chute |
has_session | (project_key: str, session_id: str) -> bool | síncrono, não lê payload, só consulta uma linha de meta. Serve para "continuidade no mesmo caminho" — dar resume numa sessão inexistente só estoura depois que o subprocesso sobe |
last_context | (project_key: str, session_id: str, *, scan: int = 60) -> int | quão grande era o contexto que o modelo realmente viu no último turno; 0 se não achar. Varre de trás para frente só as últimas scan entradas; soma input + cache_read + cache_creation (olhar só input_tokens subestima gravemente) |
load | async (key) -> list[SessionStoreEntry] \| None | ordenado por seq; sem linhas, retorna None |
list_sessions | async (project_key) -> list[SessionStoreListEntry] | só o transcript principal |
list_session_summaries | async (project_key) -> list[SessionSummaryEntry] | lista os resumos de sessão |
delete | async (key) -> None | ao apagar o transcript principal, apaga em cascata os dos subagents, evitando órfãos |
list_subkeys | async (key) -> list[str] | lista os sub-transcripts sob essa sessão |
close | () -> None | fecha a conexão |
O _next_mtime interno garante monotonicidade estrita — list_sessions e o sidecar de summary compartilham esse relógio; sem isso, o caminho rápido de staleness do SDK erra o julgamento.
TrimPolicy¶
@dataclass
class TrimPolicy:
keep_recent: int = 20
min_chars: int = 2000
spill_dirname: str = ".flower/spill"
enabled: bool = True
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
keep_recent | int | 20 | os N tool_result mais recentes mantêm o texto original |
min_chars | int | 2000 | resultados curtos não valem o corte |
spill_dirname | str | ".flower/spill" | relativo a workspace, tem que ficar dentro do workspace — senão o Read do agent não alcança |
enabled | bool | True | com Runtime(trim=False) isto vira False |
| Método | Assinatura | Descrição |
|---|---|---|
placeholder | (path: str, n: int) -> str | gera a linha de ponteiro que substitui o corpo |
Os dois diretórios de spill não são o mesmo. O spill_guard grava em <workbench.root>/spill/ (pode estar fora do workspace); o TrimPolicy.spill_dirname grava em <workspace>/.flower/spill/ (tem que estar dentro do workspace). Um corresponde ao "corte na hora" e o outro ao "corte no resume"; os diretórios serem diferentes é intencional, não unifique.
EphemeralPolicy¶
@dataclass
class EphemeralPolicy:
enabled: bool = True
keep_recent: int = 6
max_chars: int = 2000
text: str = "[{cmd} 的结果已过期(第 {age} 轮前),当前状态可能已变。需要请重新执行]"
Política de expiração dos resultados de comandos efêmeros.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
enabled | bool | True | desligado, a marcação de expiração não acontece de forma alguma |
keep_recent | int | 6 | os N mais recentes ficam isentos. Bem menor que os 20 do TrimPolicy |
max_chars | int | 2000 | acima disso é pulado, fica para o TrimPolicy arquivar |
text | str | ver assinatura | texto de substituição, com os placeholders {cmd} e {age} |
| Método | Assinatura | Descrição |
|---|---|---|
placeholder | (cmd: str, age: int) -> str | aplica text para gerar o corpo substituto |
Só age sobre resultados da ferramenta Bash, e o comando precisa bater com a whitelist de comandos efêmeros. Read não entra nisso — o conteúdo de um arquivo não se distorce com o tempo a ponto de enganar. Conteúdo expirado não sofre spill, é descartado direto.
is_ephemeral()¶
Decide se um comando Bash é um comando efêmero. A liberação do delegate_guard e a checagem de expiração do trim usam esta mesma função — o conjunto de comandos que o coordenador pode rodar sozinho tem que ser igual ao conjunto de resultados que serão marcados como expirados. Liberar sem recortar faz um git status expirado ocupar contexto para sempre e ainda enganar; recortar sem liberar faz o coordenador despachar um subagent para um ls, trocando 4.3k de custo de arranque por algumas dezenas de caracteres.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
cmd | str | obrigatório, posicional | a linha de comando completa |
Ordem de decisão:
- Vazio / só espaços →
False. - Substituição de comando (
$(, crase,<(,>() ou alguma forma que "muda estado" →False. - Depois de remover redirecionamentos seguros (
2>&1,&> /dev/nulle afins), ainda contém>ou<→False. - Depois de remover
&&/||/;/|, ainda sobra um&isolado (execução em background) →False. - Quebra por
&&/||/;/|, e cada segmento tem que bater com a whitelist.
Grandes categorias de verbos na whitelist: subcomandos git somente-leitura (status diff log show branch rev-parse etc.), informação de diretório e sistema (ls pwd df du date whoami env etc.), processos e containers (ps top lsof docker ps kubectl get etc.), leitura de arquivos (cat head tail wc stat find tree), busca de caminhos (which whereis command -v type), processamento de texto (grep rg sort uniq awk sed jq diff etc.).
Mesmo com o verbo na whitelist, estas formas são barradas: xargs, exec, eval, source, tee, find -delete / -ok / -fprint, sed -i, sort -o, system( e print > dentro de awk, git branch -D/-d/-m, git * --force/--hard/--prune.
A primeira versão recusava todo comando composto de forma indiscriminada, e na prática isso matou o glance por completo (as três tentativas do coordenador foram todas barradas), então passou a avaliar segmento por segmento.
TrimmingSessionStore¶
class TrimmingSessionStore(SqliteSessionStore):
def __init__(
self,
path: str | Path,
workspace: str | Path,
policy: TrimPolicy | None = None,
ephemeral: EphemeralPolicy | None = None,
) -> None
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
path | str \| Path | obrigatório | arquivo do banco |
workspace | str \| Path | obrigatório | base do diretório de spill |
policy | TrimPolicy \| None | None | sem valor, usa o TrimPolicy() padrão |
ephemeral | EphemeralPolicy \| None | None | sem valor, usa o EphemeralPolicy() padrão |
Atributos públicos: workspace, policy, ephemeral, last_report: dict[str, int].
Ordem no load(): super().load() → limpa last_report → se ephemeral.enabled, expire() → se policy.enabled, trim(). Com enabled=False, o passo inteiro é pulado.
| Método | Descrição |
|---|---|
expire(entries) | nos resultados Bash sensíveis ao tempo que expiraram, troca só o corpo, mantém o bloco. O comando é buscado no tool_use da mensagem assistant anterior; pula isCompactSummary / isMeta; pula os acima de max_chars (ficam para o trim); os últimos keep_recent são isentos. Escreve last_report["expired"] |
trim(entries) | o corpo de tool_result com >= min_chars vai para <workspace>/<spill_dirname>/<16 primeiros do sha256>.txt, e o conteúdo do bloco vira um ponteiro; os últimos keep_recent são isentos. Escreve cleared / kept / chars_saved em last_report |
Só recorta texto puro: blocos image / document são deixados como estão.
Duas linhas vermelhas estruturais: o bloco tool_result tem que continuar existindo, só se pode trocar o content (faltar um já é "Missing Tool Result Block"); entradas isCompactSummary não podem ser tocadas.
trim_report()¶
Renderiza store.last_report como uma linha de texto em chinês, para log da UI.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
store | TrimmingSessionStore | obrigatório, posicional | aceita também a subclasse PruningSessionStore |
Três saídas possíveis: nada feito → "未裁剪"; só expiração → "N 个时效性结果标记为过期"; caso contrário "裁掉 N 个工具结果(保留最近 M 个),省下 ~X tokens", onde X = chars_saved // 4.
PrunePolicy¶
@dataclass
class PrunePolicy:
drop_api_errors: bool = True
neutralize_interrupts: bool = True
interrupt_text: str = "[上一轮在此处被中断,该工具结果未产生]"
heal_orphans: bool = True
orphan_text: str = "[这一步被打断了,没有结果。需要的话重做。]"
keep_denials: int = 1
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
drop_api_errors | bool | True | remove mensagens sintéticas de erro da API (resíduo de queda de conexão) |
neutralize_interrupts | bool | True | tool_result residual de interrupção vira uma nota neutra |
interrupt_text | str | ver assinatura | o texto dessa nota neutra |
heal_orphans | bool | True | insere um resultado sintético para chamadas órfãs ("tem tool_use mas não tem tool_result") |
orphan_text | str | ver assinatura | o corpo do tool_result inserido |
keep_denials | int | 1 | mantém as N chamadas de ferramenta recusadas mais recentes |
heal_orphans cura o caso de todo resume dar 400 depois de uma interrupção: a interrupção corta na fronteira de mensagem, e o tool_use que estava em voo pode simplesmente não ter tool_result depois dele, enquanto a API exige os dois em par — esse histórico defeituoso fica no transcript e derruba cada uma das tentativas de resume seguintes. heal_orphans() insere uma entrada user logo após a mensagem assistant que contém o órfão, completando o resultado que falta, e redireciona o parentUuid que apontava para aquela assistant para a entrada inserida, mantendo a cadeia contínua (prune.py:95-147). Completa em vez de remover: remover o órfão exigiria refazer a cadeia pai-filho da assistant, e na mesma mensagem pode haver blocos normais, texto e thinking, o que facilmente causa dano colateral (prune.py:195-204).
O motivo de keep_denials: uma chamada recusada nunca foi executada, não há informação no resultado, mas ocupa espaço razoável (medido: 273 caracteres em um caso = 93 caracteres de texto de recusa + 180 caracteres do comando morto na íntegra). Mais grave: ela engana — medido na prática, o coordenador, depois de ler algumas entradas de "não use Bash diretamente", parou de tentar até mesmo o git status liberado, aprendendo desamparo adquirido. O padrão é 1 e não 0: a recusa mais recente evita que o modelo repita várias vezes o mesmo comando barrado dentro do mesmo turno.
PruningSessionStore¶
class PruningSessionStore(TrimmingSessionStore):
def __init__(
self,
path: str | Path,
workspace: str | Path,
policy: TrimPolicy | None = None,
prune: PrunePolicy | None = None,
ephemeral: EphemeralPolicy | None = None,
) -> None
O store padrão do Runtime.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
path | str \| Path | obrigatório | arquivo do banco |
workspace | str \| Path | obrigatório | base do diretório de spill |
policy | TrimPolicy \| None | None | política de trim |
prune | PrunePolicy \| None | None | política de prune |
ephemeral | EphemeralPolicy \| None | None | política de expiração |
Além dos da classe-mãe, três atributos públicos: prune_policy, pruned, denials_dropped.
load() = super().load() (primeiro expire + trim) → self.prune(entries). O prune faz três coisas:
- Remove as chamadas recusadas antigas demais: identificadas pela marca estrutural
toolDenialKind == "permission-rule"do harness (mais confiável do que casar o texto da recusa), mantendo as últimaskeep_denialse removendo, das demais, o blocotool_usee otool_resultjuntos. Quando há váriostool_usena mesma mensagem assistant, só o acertado é removido, senão vira "Missing Tool Result Block"; blocos de texto e thinking são preservados. - Remove as mensagens sintéticas de erro da API. Elas continuam intactas no SQLite, só não são realimentadas.
- Troca o
tool_resultresidual de interrupção por uma nota neutra — só o corpo, a entrada não é removida.
A única linha vermelha estrutural: o transcript é uma cadeia simples por parentUuid; ao remover uma entrada é obrigatório reconectar seus filhos ao ancestral vivo mais próximo. O entries do relink interno tem que ser a lista completa (incluindo as que serão removidas), pois a filtragem é feita por ele mesmo — se quem chama filtrar antes de passar, a cadeia quebra ali e todo o histórico anterior se perde (já pisamos nisso: não aparece quando as entradas removidas estão no fim, mas estoura quando estão no meio).
A ordem dos parâmetros difere da classe-mãe: a mãe é (path, workspace, policy, ephemeral), a filha é (path, workspace, policy, prune, ephemeral) — o quarto posicional mudou de ephemeral para prune, e passar por posição desloca tudo silenciosamente. Sempre passe por palavra-chave.
Resiliência¶
Código-fonte: flower/core/resilience.py
Quando a rede cai, fica pendurado esperando em vez de sair com falha. Quatro exports: uma dataclass de política + três funções de sondagem que podem ser usadas isoladamente.
Resilience¶
@dataclass
class Resilience:
enabled: bool = True
max_attempts: int = 6
base_delay: float = 4.0
max_delay: float = 120.0
probe_timeout: float = 5.0
probe_interval: float = 15.0
max_offline_wait: float = 3600.0
retry_unknown: bool = True
resume_prompt: str = "上一轮在中途被打断,没有跑完。检查一下工作台里已经落盘的东西,从中断处接着做,不要重头来过。"
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
enabled | bool | True | desligado, nenhuma falha é retentada |
max_attempts | int | 6 | inclui a primeira tentativa |
base_delay | float | 4.0 | base do backoff, em segundos |
max_delay | float | 120.0 | teto do backoff, em segundos |
probe_timeout | float | 5.0 | timeout de uma sondagem |
probe_interval | float | 15.0 | espera entre duas sondagens |
max_offline_wait | float | 3600.0 | quanto tempo no máximo fica pendurado esperando; padrão 1 hora |
retry_unknown | bool | True | se erros não classificáveis devem ser retentados |
resume_prompt | str | ver assinatura | o que se diz ao retomar. Deliberadamente sem nenhum detalhe do erro — o modelo precisa saber "foi interrompido, continue", não se foi ENOTFOUND ou 503 |
| Método | Assinatura | Descrição |
|---|---|---|
delay_for | (attempt: int) -> float | min(base_delay * 2**(attempt-1), max_delay) multiplicado por 0.75 + random()*0.5 (jitter de ±25%) |
should_retry | (kind: str) -> bool | kind == "transient", ou kind == "unknown" com retry_unknown |
wait_online | async (notify=None) -> bool | fica pendurado esperando a rede voltar. Voltou, retorna True; passou de max_offline_wait, retorna False. notify é um callback (str) -> None, disparado uma vez na primeira vez que fica inacessível e uma vez na recuperação |
classify()¶
Classifica o texto do erro em três categorias: "transient" / "fatal" / "unknown".
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
text | str \| None | obrigatório, posicional | a mensagem de erro original. Vazia retorna "unknown" |
Testa fatal antes de transient — textos como os de 401 costumam trazer a palavra connection; com a ordem invertida, ficaria esperando para sempre.
| Categoria | O que casa |
|---|---|
fatal | 400 401 403 404, invalid api key, authentication, unauthorized, permission denied, invalid_request, credit balance, quota exceeded, budget, max_turns, CLINotFound |
transient | ENOTFOUND EAI_AGAIN ECONNRESET ECONNREFUSED ETIMEDOUT EPIPE EHOSTUNREACH ENETDOWN, socket hang up, fetch failed, network error, Connection error, Can't reach the API server, 429 500 502 503 504 529, overloaded, rate limit, too many requests, timeout / timed out, temporarily unavailable, service unavailable, internal server error |
endpoint()¶
Host e porta a sondar, seguindo ANTHROPIC_BASE_URL, com padrão https://api.anthropic.com; a porta padrão é 80 (http) ou 443.
Ao usar um gateway próprio, é ele que precisa ser sondado — api.anthropic.com responder não prova nada sobre o gateway.
reachable()¶
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
host | str | obrigatório, posicional | nome do host |
port | int | obrigatório, posicional | porta |
timeout | float | 5.0 | segundos |
Faz só DNS (getaddrinfo) + handshake TCP, sem HTTP, sem credenciais, sem gastar dinheiro. Qualquer exceção conta como inacessível.
Eventos e interação¶
Código-fonte: events.py · human.py
Evento é a estrutura estável em que o fluxo de mensagens do SDK é achatado. A camada de interação só conhece Event, não importa nenhum tipo do SDK — é essa a fronteira que permite trocar de UI sem mexer no núcleo. Veja Trocar a camada de interação.
Event¶
@dataclass
class Event:
kind: EventKind
text: str = ""
tool: str = ""
payload: dict[str, Any] = field(default_factory=dict)
raw: Any = None
def __str__(self) -> str: ...
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
kind | EventKind | obrigatório | veja a tabela abaixo |
text | str | "" | corpo do texto |
tool | str | "" | nome da ferramenta, só existe em tool_call |
payload | dict[str, Any] | {} | informação adicional estruturada |
raw | Any | None | objeto original do SDK, para quando você quiser cavar fundo |
__str__: em tool_call é f"[{tool}] {text}", caso contrário é text, e se text estiver vazio é f"<{kind}>". Ou seja, print(ev) já sai legível.
EventKind tem 15 valores no total:
| kind | Quem emite | Descrição |
|---|---|---|
text | normalize | corpo de texto do assistant |
thinking | normalize | bloco de raciocínio |
tool_call | normalize | chamada de ferramenta. text é o resumo de file_path / command / pattern, cortado em 200 caracteres |
tool_result | normalize | resultado da ferramenta. text cortado em 500 caracteres, payload traz tool_use_id / is_error |
task | normalize | três tipos de mensagem Task. text está vazio, o nome da classe fica em payload["kind"] |
system | normalize | demais mensagens de sistema, text é o subtype |
reset | normalize | compact_boundary / microcompact_boundary / ConversationResetMessage |
result | normalize | ResultMessage, payload traz session_id / cost_usd / num_turns / is_error |
error | normalize | mensagem sintética de erro de API, payload traz {"synthetic": True} |
prompt | normalize | UserMessage. O corpo é entrada, não produção do modelo, por isso não entra em StepResult.text |
unknown | normalize | não reconhecido |
retry | Runtime | aviso de retentativa |
step | Workflow.run | payload: {"index", "total", "resumed", "woke"} |
handoff | Runtime | no payload, phase ∈ {"near", "writing", "done"} |
ask | HumanChannel | pergunta, também carrega "o que a pessoa disse por iniciativa própria" |
Os quatro últimos não são produzidos por normalize().
Todos os eventos de assistant / user trazem no payload:
| Chave | Tipo | Descrição |
|---|---|---|
subagent | bool | bool(parent_tool_use_id) |
parent_tool_use_id | str | só existe quando subagent é verdadeiro |
context | int | input_tokens + cache_read_input_tokens + cache_creation_input_tokens. É a única fonte do critério de handoff, e também o número que mais merece ser visto numa run long-horizon |
O kind ask carrega ao mesmo tempo "pergunta" e "fala espontânea da pessoa". No segundo caso, payload["kind"] == "mail", sem options / remaining. A UI precisa checar payload.get("kind") antes de decidir como renderizar, senão vai tratar uma frase como uma pergunta pendente e ficar travada nela.
normalize()¶
Achata uma mensagem do SDK em 0 a N Event.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
message | Any | obrigatório, posicional | qualquer objeto de mensagem do SDK |
Ramificações-chave:
- Mensagem sintética de erro de API (
isApiErrorMessage=Trueoumodel == "<synthetic>") → um únicoEvent("error", payload={"synthetic": True}). Isso é intencional — caso contrário o texto de queda de conexão seria tratado como corpo, entraria emStepResult.texte seria repassado ao passo seguinte. AssistantMessage→kind="text";UserMessage→kind="prompt".ToolUseBlock→Event("tool_call", text=<resumo>, tool=block.name, payload={"id", "input"}).ToolResultBlock→Event("tool_result", text=content[:500], payload={"tool_use_id", "is_error"}).ResultMessage→Event("result", text=subtype, payload={"session_id", "cost_usd", "num_turns", "is_error"}).compact_boundary/microcompact_boundary→Event("reset", payload={"trigger", "pre_tokens", "post_tokens", "micro", "subtype"}).
Ask¶
@dataclass
class Ask:
id: str
question: str
options: list[str] = field(default_factory=list)
asked_at: float = field(default_factory=time.time)
state: str = "asked"
answer: str = ""
Uma pergunta feita à pessoa.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
id | str | obrigatório | usado para localizar a pergunta na hora de responder |
question | str | obrigatório | corpo da pergunta |
options | list[str] | [] | alternativas. A pessoa também pode não escolher e digitar a própria resposta |
asked_at | float | time.time() | momento da pergunta |
state | str | "asked" | asked → answered / timeout / declined / over_budget / invalid |
answer | str | "" | corpo da resposta |
| Membro | Assinatura | Descrição |
|---|---|---|
waited_s | @property -> float | há quanto tempo está esperando |
event | (remaining: int = 0) -> Event | produz Event("ask", text=question, payload={"id", "options", "state", "answer", "remaining", "asked_at"}, raw=self) |
HumanChannel¶
HumanChannel(
*,
on_event: Callable[[Event], None] | None = None,
max_asks: int | None = None,
timeout_s: float | None = 1800.0,
log_path: str | Path | None = None,
amend_path: str | Path | None = None,
over_budget_text: str = OVER_BUDGET,
timeout_text: str = TIMEOUT,
declined_text: str = DECLINED,
)
Um servidor MCP dentro do processo (duas ferramentas) + um conjunto de métodos para a UI. Do lado do modelo só aparecem mcp__human__ask e mcp__human__inbox. Todos os parâmetros do construtor são keyword-only.
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
on_event | Callable[[Event], None] \| None | None | a saída do modo push. Se você passar isso, Workflow.run não conecta mais nada |
max_asks | int \| None | None | sem limite de vezes. Um número é cota rígida, 0 = proibido perguntar (totalmente automático / CI). Ao estourar, a ferramenta recusa direto, sem bloquear |
timeout_s | float \| None | 1800.0 | 30 minutos. None = espera para sempre; <= 0 = não espera, toda pergunta cai no vazio imediatamente |
log_path | str \| Path \| None | None | perguntas e respostas são anexadas ao disco, não ocupam contexto |
amend_path | str \| Path \| None | None | o que a pessoa disser no meio da run é anexado a este arquivo (em geral o próprio brief). Sem spill não sobrevive à fronteira de passo — o próximo passo é uma sessão nova, que só lê o artefato congelado |
over_budget_text | str | constante do módulo | o que responder ao modelo quando a cota estourar |
timeout_text | str | constante do módulo | o que responder ao modelo em caso de timeout |
declined_text | str | constante do módulo | o que responder ao modelo quando a pergunta for pulada |
Atributos públicos: os oito com o mesmo nome dos parâmetros do construtor, mais asks: list[Ask], mail: list[Mail] e ui_errors: list[str] (exceções lançadas pelo callback da UI são recolhidas aqui, sem interromper a run).
| Membro | Assinatura | Descrição |
|---|---|---|
tool_name | @property -> str | "mcp__human__ask" |
inbox_name | @property -> str | "mcp__human__inbox" |
mcp_servers | () -> dict[str, Any] | entra direto em AgentSpec.mcp_servers. A chave precisa bater com o nome do server, por isso é ele quem entrega o dicionário pronto |
ask | async (question: str, options: list[str] \| None = None) -> Ask | fica pendurado esperando a pessoa. Fora de CancelledError, nunca lança exceção — ninguém responder também é uma resposta, distinga por ask.state |
send | (text: str) -> Mail \| None | a pessoa fala algo por iniciativa própria. Pode ser chamado de qualquer thread. Não interrompe o agent; internamente chama amend() automaticamente |
amend | (text: str, *, label: str = "运行中补充") -> bool | anexa em amend_path. Retorna se realmente escreveu (sem caminho configurado, texto vazio ou OSError são todos False) |
pending_mail | () -> list[Mail] | mails ainda não retirados |
remaining | @property -> int | quantas perguntas restam. Com max_asks=None retorna -1, não 0 nem infinito |
pending | () -> list[Ask] | perguntas atualmente penduradas aguardando resposta |
next_ask | async (timeout: float \| None = None) -> Ask \| None | usado no modo pull. Retorna None em timeout; se for cancelado, lança |
answer | (ask_id: str, text: str) -> bool | responde. False = essa pergunta já não está esperando (timeout / já respondida) |
decline | (ask_id: str, reason: str = "") -> bool | pula, deixando o modelo decidir sozinho |
transcript | () -> str | o markdown do registro de perguntas e respostas |
Escolha uma das duas formas de consumir: push — construa HumanChannel(on_event=...); pull — await channel.next_ask(). Workflow.run só faz a ligação automática quando channel.on_event is None, então se você passou o seu, ele não é sobrescrito.
Entre threads: answer / decline / send usam loop.call_soon_threadsafe internamente, chamar direto de um backend Web / thread de entrada da TUI é o uso normal.
Os três "0 / None" têm semânticas diferentes, não misture: max_asks=None = sem limite, max_asks=0 = proibido perguntar; timeout_s=None = espera para sempre, timeout_s<=0 = timeout imediato; remaining é -1 quando max_asks=None.
Mail, que não é exportado mas aparece em valores de retorno, é um dataclass com os campos id / text / sent_at / taken.
Linhagem¶
Código-fonte: flower/core/lineage.py
Lineage¶
@dataclass
class Lineage:
path: Path
workspace: Path
steps: dict[str, str] = field(default_factory=dict)
woke: int = 0
Registra entre processos "qual passo usou qual sessão"; é por ela que a continuidade descobre onde a run parou. O arquivo é <run_dir>/lineage.json.
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
path | Path | obrigatório | caminho do arquivo de linhagem |
workspace | Path | obrigatório | área de trabalho. __post_init__ faz resolve |
steps | dict[str, str] | {} | nome do passo → session_id |
woke | int | 0 | quantas vezes acordou |
| Membro | Assinatura | Descrição |
|---|---|---|
open | @classmethod (run_dir: str \| Path, workspace: str \| Path) -> Lineage | lê <run_dir>/lineage.json. Se o arquivo não existir, não puder ser lido, ou o campo workspace não bater, retorna vazio sem erro |
remember | (step: str, session_id: str) -> None | guarda o mapeamento e faz spill na hora. Step vazio ou sid vazio retornam direto |
bump | () -> int | incrementa o contador de wake em 1, faz spill, retorna o novo valor (na primeira execução é 1) |
archive | (into: str \| Path, *, extra: list[Path] \| None = None) -> Path | move o arquivo de linhagem + extra para <into>/<YYYYmmdd-HHMMSS>/ e zera steps / woke. Mover não é apagar |
O spill usa substituição atômica via tmp.replace(path); OSError é engolido em silêncio — uma falha de escrita não deve derrubar a run.
workspace é uma guarda: a project_key do SDK é derivada do caminho da área de trabalho; se o diretório for copiado para outro lugar, os session_id antigos não são encontrados, então caminho que não bate é tratado como inexistente.
Ao carregar a linhagem, Workflow.run valida cada registro com runtime.has_session(sid) para ver se ainda está no banco, e só usa o que estiver vivo — o arquivo de linhagem pode viver mais que o sessions.db.
Exemplos mínimos utilizáveis¶
Os cinco trechos rodam direto. Pré-requisito: claude-agent-sdk instalado e ANTHROPIC_API_KEY ou ANTHROPIC_AUTH_TOKEN disponível (caso contrário Runtime(...) já lança RuntimeError na construção).
Um agent rodando um passo¶
Esqueleto mínimo: declare um AgentSpec, crie um Runtime, await rt.run(...), leia o StepResult.
import asyncio
from pathlib import Path
from flower import AgentSpec, Runtime
spec = AgentSpec(
name="reader",
instructions="回答极简,一行以内,不解释不寒暄。",
allowed_tools=["Read", "Glob", "Grep"],
max_turns=4,
)
async def main() -> None:
rt = Runtime(workspace=Path("."), run_dir="runs")
try:
r = await rt.run(spec, "读 README.md,一句话说它是干什么的。",
on_event=lambda ev: print(ev))
print(f"ok={r.ok} session={r.session_id} ${r.cost_usd:.4f} {r.duration_s}s")
print(r.text)
finally:
rt.close()
asyncio.run(main())
Os parâmetros de Runtime são todos keyword-only; em rt.run(), spec e prompt são posicionais e o resto é keyword-only. AgentSpec tem por padrão allowed_tools=["Read", "Glob", "Grep"] e delegate_only=False, então o Runtime instala automaticamente o whitelist_guard nele, barrando Bash/Write/Edit/NotebookEdit.
Coordenador + executor¶
Um coordenador que não põe a mão na massa com um executor que trabalha. Essa é a primeira camada de economia de contexto do flower.
import asyncio
from pathlib import Path
from flower import Runtime, coordinator, worker
async def main() -> None:
analyst = worker(
"分析文件内容:统计、查找、比对。要真读文件、跑命令的活派给它。",
"你负责在 data/ 下做文本分析。用命令行完成,不要手工估算。",
tools=["Read", "Write", "Bash", "Glob", "Grep"],
)
boss = coordinator(
"主控",
"目标:摸清 data/ 下几个文件的规模。做完给一句话结论。",
{"分析员": analyst},
max_turns=14,
max_budget_usd=1.5,
)
# workbench=True é obrigatório: o delegate_guard fica pendurado em workbench_hooks;
# sem a workbench, o Bash/Write do coordenador não tem nenhum hook barrando.
rt = Runtime(workspace=Path("."), run_dir="runs", workbench=True)
try:
r = await rt.run(boss, "统计 data/ 下每个 .txt 的行数和总字符数,告诉我哪个最大。",
on_event=lambda ev: None)
print(f"ok={r.ok} turns={r.num_turns} ${r.cost_usd:.4f}")
print(r.text)
finally:
rt.close()
asyncio.run(main())
Os dois primeiros parâmetros de worker() são posicionais: description (o coordenador usa para escolher quem chamar) e prompt (o system prompt dele, com WORKER_RULES concatenado automaticamente depois). Em coordinator(), os três primeiros são posicionais: name, instructions, workers.
Escrevendo seu próprio Workflow¶
Dois passos, com o segundo injetando o resultado do primeiro no próprio prompt — barato, isolado, sem compartilhar sessão.
import asyncio
from pathlib import Path
from flower import AgentSpec, Runtime, Step, Workflow
terse = AgentSpec(
name="terse",
instructions="回答极简,一行以内,不解释不寒暄。",
allowed_tools=["Read", "Glob"],
max_turns=4,
)
async def main() -> None:
wf = Workflow([
# Sessão nova: só consome o que está no prompt
Step("取词", terse, "读 seed.txt,只回文件里那个词。"),
# Sessão nova + resultado do passo anterior injetado no prompt (barato, evita contaminação)
Step("造句", terse, lambda ctx: f"用「{ctx['取词']}」造一个五字短句,只回短句。"),
# Para continuar falando na mesma sessão, escreva resume_from="造句"; para bifurcar, acrescente fork=True
])
rt = Runtime(workspace=Path("."), run_dir="runs")
try:
ctx = await wf.run(rt, on_step=lambda s, r: print(f"{s.name} ok={r.ok} {r.text[:40]!r}"))
finally:
rt.close()
print(ctx["造句"]) # ctx[step.name] = result.text (quando não há reduce)
print(ctx["_sessions"]) # step name -> session_id
print(ctx.get("_failed_at")) # com on_fail="stop", em qual passo falhou
asyncio.run(main())
Os três primeiros campos de Step (name / spec / prompt) são posicionais, e o steps de Workflow também. Workflow.run(runtime, *, on_event=None, on_step=None) — runtime posicional, os dois callbacks keyword-only. Atenção: continuous=True é o padrão: na segunda execução com o mesmo run_dir + o mesmo workspace, até os passos com resume_from=None continuam falando na mesma sessão da vez anterior.
Acrescentando um goal guard¶
Primeiro o judge fixa o objetivo e a lista de critérios, depois o passo de trabalho passa a aceitar o veredito — se não passar, refaz com o feedback, no máximo três rodadas.
import asyncio
from pathlib import Path
from flower import (HumanChannel, Runtime, Step, Workbench, Workflow,
coordinator, goal_step, with_goal, worker)
async def main() -> None:
wb = Workbench(Path.cwd()).ensure()
# timeout_s=0 = totalmente automático: toda pergunta cai no vazio na hora, sem fingir que espera alguém
ch = HumanChannel(log_path=wb.notes / "问答记录.md", timeout_s=0)
goal_path = wb.notes / "目标.md"
coord = coordinator("协调者", "", {
"coder": worker("写代码与测试。要动手实现的活派给它。",
"你负责实现。每改一处就跑一次验证,别攒到最后。"),
}, channel=ch)
work = Step("干活", spec=coord, prompt="把 hello.py 写出来,跑 `python hello.py` 要打印 hello。")
# rounds é o **total de rodadas**: rounds=3 → retries=2 → no máximo três rodadas de trabalho
work = with_goal(work, ch, goal_path=goal_path, rounds=3, can_run=True)
wf = Workflow(
[goal_step(ch, goal_path=goal_path), work],
channel=ch,
workbench=wb,
# O prompt do goal_step lê ctx["确认需求"] (valor padrão de brief_key).
# Sem um clarify_step, preencha você mesmo; senão ele só verá "(没有确认书)".
context={"确认需求": "## 目标\n写一个打印 hello 的 python 脚本\n\n## 验收标准\n跑 `python hello.py` 输出 hello"},
)
rt = Runtime(workspace=Path.cwd(), run_dir="runs", workbench=wb)
try:
ctx = await wf.run(rt)
finally:
rt.close()
print(ctx["_goal"]) # GOAL_KEY: objeto Goal
print(ctx["_verdict"]) # VERDICT_KEY: o Verdict mais recente
print(ctx["_goal_rounds"]) # ROUND_KEY: quantas rodadas rodaram
print(ctx.get("_aborted")) # motivo do StepAbort (inalcançável e sem ninguém respondendo)
asyncio.run(main())
with_goal só troca gate / on_reject / retries; os demais campos são levados intactos por dataclasses.replace. O judge roda em sessão independente: internamente o gate chama rt.run(judger, ..., step_name=f"{label}#{轮次}") à parte, com resume sempre None.
Trocando a camada de interação¶
Para trocar o terminal por Web / TUI / HTTP, só é preciso mudar duas coisas: a função que renderiza Event e a corrotina que pega as perguntas.
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" ~ handoff/{ev.payload.get('phase')}: {ev.text}")
elif ev.kind == "retry":
print(f" ~ retry: {ev.text}")
elif ev.kind == "ask" and ev.payload.get("kind") == "mail":
print(f" ~ 人主动说:{ev.text}")
# kind == "ask" que não seja mail fica a cargo do answerer abaixo (modo pull)
async def answerer(ch: HumanChannel) -> None:
"""Coleta de perguntas em modo pull. Ao trocar por backend Web / serviço HTTP, esta corrotina é o único 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 a workbench que o workflow já criou — não monte outra
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
task = asyncio.create_task(answerer(wf.channel))
try:
await wf.run(rt, on_event=sink)
finally:
task.cancel()
rt.close()
asyncio.run(main())
Push e pull: escolha um. Push é construir HumanChannel(on_event=...), pull é await channel.next_ask(). Workflow.run só faz a ligação automática quando channel.on_event is None, então se você passou o seu on_event, ele não é sobrescrito. answer() / decline() / send() / interrupt() podem todos ser chamados de outra thread.
Armadilhas e pontos de erro¶
Na ordem em que você vai tropeçar, não por módulo. Cada item tem origem medida na prática.
Montagem¶
Runtime(workbench=False)+coordinator()= nenhuma barreira na main thread. Odelegate_guardsó é instalado quando existe workbench, e owhitelist_guardé pulado pordelegate_only=True. Se usa coordenador, ligue a workbench. Veja Runtime.- A workbench tem dois lugares possíveis, não confunda.
Runtime(workbench=True)cai em<run_dir>/workbench;Workbench(ws)cai por padrão em<ws>/.flower. Ao montarbrief_pathna mão, siga o segundo caso, senão o brief é escrito no diretório A, o índice injetado varre o diretório B, e não dá erro nenhum. Jeito correto: o próprio workflow fazWorkbench(...).ensure()e pendura emWorkflow.workbench, e então entrega o mesmo objeto paraRuntime(workbench=wb). allowed_toolsnão é uma whitelist exclusiva, é uma lista de dispensa de aprovação. O modelo continua podendo chamar ferramentas que não estão nela. O "não tem ferramenta de escrita" declarify()/judge()depende do hookwhitelist_guard. Jácoordinator()usa por padrãopermission_mode="acceptEdits"— quem repassar esse valor paraclarify()/judge()perde a proteção.disallowed_toolsé a nível de sessão, e proíbe junto as ferramentas de mesmo nome nos subagents.- O índice da workbench não chega aos subagents. "Produção longa vai para
artifacts/" precisa ser repassado pelo coordenador no task brief; esse é o único canal. Runtime(...)lançaRuntimeErrorjá na construção quando não há credencial, não na hora dorun().Runtime.run_idprecisa ser único por instância. Omanifest.jsondeduplica pelo camporun; quando dois ids colidem, o que escreve depois apaga as linhas do outro achando que são "as que eu mesmo escrevi da última vez".
Workflow¶
Workflow.continuous=Trueé o padrão, eresume_from=Nonenão significa "sessão totalmente nova". Para abrir sessão nova sempre, passe explicitamentecontinuous=False. Mudar o nome de um passo equivale a cortar a linhagem.with_goal(rounds=N)é o total de rodadas, não rodadas extras:retries = max(0, rounds - 1).on_fail="skip"não escrevectx[step.name]— umlambda ctx: ctx["某步"]a jusante dáKeyError. Para seguir adiante carregando o resultado incompleto, useon_fail="continue".resume_fromapontando para um passo não executado / que falhou lançaValueError, não pula em silêncio.Step.reduceprecisa ser função síncrona;gate/when/on_rejectpodem ser async.fork=Truesemresumeé silenciosamente inócuo. OWorkflownunca passaresume_at; para retroceder por mensagem só chamandoRuntime.rundiretamente.- Ao dirigir o
Runtimevocê mesmo,on_sessionprecisa ser removido antes do gate, senão a sessão do judge é gravada na linhagem do passo de trabalho. OWorkflowgarante isso comtry/finally. step_namedefine a chave no manifest e na linhagem. OWorkflowacrescenta sufixos#retryN/#roundN, e o judge acrescenta#轮次— nomes com sufixo não entram na linhagem entre processos, e é exatamente assim que se implementa "o judge é sempre sessão nova".
Papéis¶
clarify(max_turns=<número pequeno>)transforma "perguntas ilimitadas" em conversa fiada — cada pergunta é um turno.goal_step()não tem o parâmetro formalcan_run, só dá para passarcan_run=Truevia**spec_kw. Sem isso, o judge que define o objetivo não recebeBash, e a regra "primeiro entenda em que ambiente você está" dasJUDGE_RULESnão pode ser executada.judge(can_run=True)permite que o judge altere a área de trabalho — owhitelist_guardé derivado deallowed_tools, e darBashliberaBash(ainda bloqueiaWrite/Edit, mas o próprioBashescreve arquivos). Para neutralidade absoluta, não ligue.worker(isolate=True)exige que a workspace seja um repositório git, senão a ferramentaAgentretorna direto"not in a git repository", sem degradar em silêncio. Além disso, a marca de isolamento é um atributo Python, e fazerdataclasses.replace()sobre umAgentDefinitiona perde.- Ao construir
AgentDefinitiondiretamente, os parâmetros são camelCase:maxTurns,permissionMode.worker()já faz essa conversão por você.
Handoff e contexto¶
- Com o handoff ligado, o auto-compact é forçadamente desligado, sem rede de segurança. Por isso o passo que escreve o handoff document precisa ter caminho de degradação. Para manter o auto-compact, passe
AgentSpec.compactexplicitamente. - Um
HandoffPolicy.windowpequeno demais gera handoffs infinitos queimando dinheiro. A única comporta émax_generations=8. Do outro lado,default_window()também retorna1_000_000quando nenhuma das duas variáveis de ambiente está definida — um valor alto demais é amparado poris_overflow()(vira um handoff degradado), não é erro fatal, mas o handoff daquela geração é degradado. - Sem workbench, o handoff não faz spill. O documento continua sendo entregue ao sucessor pelo prompt, mas depois ninguém consegue recuperá-lo.
Armazenamento¶
Runtime(trim=False)(padrão) não significa "não limpa nada". O store é semprePruningSessionStore;trim=Falsesó desliga o trim de resultados grandes; remover resíduo de queda de conexão, remover chamadas recusadas, neutralizar restos de interrupção e expirar conteúdo com prazo continuam acontecendo.- Os dois diretórios de spill não são o mesmo: o
spill_guardcai em<workbench.root>/spill/, eTrimPolicy.spill_dirnamecai em<workspace>/.flower/spill/(precisa estar dentro da área de trabalho). - O quarto parâmetro posicional de
PruningSessionStore.__init__éprune, nãoephemeral, diferente da classe base. Passar por posição desalinha silenciosamente.
Documentos e interação¶
- Quando o
Verdictnão consegue extrair a conclusão,state=""eok=False; isso jamais pode ser tratado como aprovado. Além disso, "无法验证 / 没法验证 / 验证不了 / 无法判定 / unverifiable" caem todos emunreachable, o que dispara o caminho de "parar e perguntar à pessoa", não o de "mais uma rodada". Brief.parsedescarta todo o conteúdo posterior a uma cerca de código não fechada — quando a saída do modelo é truncada, nenhuma das seções seguintes é analisada,complete()ficaFalsee o gate manda refazer.Brief.loadtrata"(未填)"como vazio. Se, ao editar o brief à mão, você copiar o texto de placeholder, aquela seção continua contando como faltante.- Os três "0 / None" de
HumanChanneltêm semânticas diferentes:max_asks=Nonesem limite,max_asks=0proibido perguntar;timeout_s=Noneespera para sempre,timeout_s<=0timeout imediato;remainingretorna-1quandomax_asks=None. Event("ask")carrega ao mesmo tempo perguntas e falas espontâneas da pessoa, compayload["kind"] == "mail"no segundo caso. A UI precisa checar isso antes.Workflow.runsó faz a ligação automática quandochannel.on_event is None— se você construirHumanChannel(on_event=...)por conta própria, os eventos de pergunta não passam também pela saídaon_eventdo workflow.